Merge branch 'release/25.03.0' into main
This commit is contained in:
commit
18b9593c14
1898 changed files with 11788 additions and 6033 deletions
2
.github/workflows/danger.yml
vendored
2
.github/workflows/danger.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
- run: |
|
||||
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
||||
- name: Danger
|
||||
uses: danger/danger-js@12.3.3
|
||||
uses: danger/danger-js@12.3.4
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile.js"
|
||||
env:
|
||||
|
|
|
|||
4
.github/workflows/quality.yml
vendored
4
.github/workflows/quality.yml
vendored
|
|
@ -138,7 +138,7 @@ jobs:
|
|||
- name: Build Fdroid Debug
|
||||
run: ./gradlew :app:compileFdroidDebugKotlin $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Run lint
|
||||
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
@ -294,7 +294,7 @@ jobs:
|
|||
yarn add danger-plugin-lint-report --dev
|
||||
- name: Danger lint
|
||||
if: always()
|
||||
uses: danger/danger-js@12.3.3
|
||||
uses: danger/danger-js@12.3.4
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
|
||||
env:
|
||||
|
|
|
|||
101
CHANGES.md
101
CHANGES.md
|
|
@ -1,3 +1,104 @@
|
|||
<!-- Release notes generated using configuration in .github/release.yml at v25.02.0 -->
|
||||
|
||||
## What's Changed
|
||||
### ✨ Features
|
||||
* Media navigation with swipe gesture by @bmarty in https://github.com/element-hq/element-x-android/pull/4161
|
||||
* Add ability to swipe between media when opened from the timeline. by @bmarty in https://github.com/element-hq/element-x-android/pull/4205
|
||||
### 🙌 Improvements
|
||||
* change(design) : use ElementTheme.typography.fontBodyLgMedium by @ganfra in https://github.com/element-hq/element-x-android/pull/4145
|
||||
* change(design) : New component Announcement by @ganfra in https://github.com/element-hq/element-x-android/pull/4140
|
||||
* update rust sdk 0.2.75 by @ganfra in https://github.com/element-hq/element-x-android/pull/4158
|
||||
### 🐛 Bugfixes
|
||||
* Fix dm avatar rtl by @bmarty in https://github.com/element-hq/element-x-android/pull/4103
|
||||
* Unified push gateway resolver improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/4101
|
||||
* Close the media preview screen ASAP with sending queue enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4089
|
||||
* fix(coroutine) : make sure to switch coroutine context by @ganfra in https://github.com/element-hq/element-x-android/pull/4146
|
||||
* Fix snack bar not displayed in MediaViewer by @bmarty in https://github.com/element-hq/element-x-android/pull/4195
|
||||
* Let the SDK provide the "network is available information" by @bmarty in https://github.com/element-hq/element-x-android/pull/4215
|
||||
### 🗣 Translations
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4088
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4100
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4114
|
||||
* Fix import of en-US translations. by @bmarty in https://github.com/element-hq/element-x-android/pull/4135
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4139
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4172
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4199
|
||||
* Sync Strings - new (partial) language: Norwegian by @ElementBot in https://github.com/element-hq/element-x-android/pull/4227
|
||||
### 🧱 Build
|
||||
* Update Gradle Wrapper from 8.11.1 to 8.12 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4085
|
||||
* Test using Maestro CLI + emulator instead of Cloud by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4092
|
||||
* Make Maestro run for each PR push by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4121
|
||||
* Migrate to CalVer like versioning by @bmarty in https://github.com/element-hq/element-x-android/pull/4187
|
||||
* Kover: include back :libraries:matrix:impl module. by @bmarty in https://github.com/element-hq/element-x-android/pull/4193
|
||||
* Update Gradle Wrapper from 8.12 to 8.12.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4196
|
||||
* Use secret Sentry DSN value by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4210
|
||||
* Use Sentry breadcrumbs instead of logging new events by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4223
|
||||
### 🚧 In development 🚧
|
||||
* Media Viewer: show snackbar when reaching end of timeline. by @bmarty in https://github.com/element-hq/element-x-android/pull/4201
|
||||
* Feature : room settings - security and privacy by @ganfra in https://github.com/element-hq/element-x-android/pull/4212
|
||||
### Dependency upgrades
|
||||
* Update dependency io.mockk:mockk to v1.13.14 by @renovate in https://github.com/element-hq/element-x-android/pull/4083
|
||||
* Update dependency net.java.dev.jna:jna to v5.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4087
|
||||
* Update kotlin to v1.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4073
|
||||
* Update dagger to v2.54 by @renovate in https://github.com/element-hq/element-x-android/pull/4084
|
||||
* Update dependency io.sentry:sentry-android to v7.19.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4090
|
||||
* Update dependency com.android.tools:desugar_jdk_libs to v2.1.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4077
|
||||
* Update dependency com.posthog:posthog-android to v3.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4120
|
||||
* Update appyx to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4129
|
||||
* Update dagger to v2.55 by @renovate in https://github.com/element-hq/element-x-android/pull/4131
|
||||
* Update android.gradle.plugin to v8.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4130
|
||||
* Update dependency org.maplibre.gl:android-sdk to v11.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4132
|
||||
* Update dependency io.mockk:mockk to v1.13.16 by @renovate in https://github.com/element-hq/element-x-android/pull/4134
|
||||
* Update dependencyAnalysis to v2.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4136
|
||||
* Update anvil to v0.4.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4144
|
||||
* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4117
|
||||
* Update plugin dependencycheck to v12 by @renovate in https://github.com/element-hq/element-x-android/pull/4137
|
||||
* Update dependency io.sentry:sentry-android to v7.20.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4107
|
||||
* Update wysiwyg to v2.38.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4104
|
||||
* Update dependency androidx.recyclerview:recyclerview to v1.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4154
|
||||
* Update activity to v1.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4152
|
||||
* Update firebaseAppDistribution to v5.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4159
|
||||
* Update dependency com.google.firebase:firebase-bom to v33.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4160
|
||||
* Update dependency androidx.compose:compose-bom to v2025 by @renovate in https://github.com/element-hq/element-x-android/pull/4155
|
||||
* Update dependency io.sentry:sentry-android to v7.20.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4178
|
||||
* Update dependency io.sentry:sentry-android to v8 by @renovate in https://github.com/element-hq/element-x-android/pull/4180
|
||||
* Update wysiwyg to v2.38.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4177
|
||||
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.76 by @renovate in https://github.com/element-hq/element-x-android/pull/4183
|
||||
* Update wysiwyg to v2.38.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4186
|
||||
* Update dependency com.posthog:posthog-android to v3.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4204
|
||||
* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4200
|
||||
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.77 by @renovate in https://github.com/element-hq/element-x-android/pull/4228
|
||||
* Update dependency com.posthog:posthog-android to v3.11.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4222
|
||||
* Update dependency io.element.android:emojibase-bindings to v1.3.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4213
|
||||
* Update dependencyAnalysis to v2.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4218
|
||||
* Update dependency androidx.compose:compose-bom to v2025.01.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4217
|
||||
* Update dependency io.sentry:sentry-android to v8.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4221
|
||||
* Update rnkdsh/action-upload-diawi action to v1.5.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4173
|
||||
* Update plugin dependencycheck to v12.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4170
|
||||
### Others
|
||||
* Improve gallery loading state by @bmarty in https://github.com/element-hq/element-x-android/pull/4080
|
||||
* Show more detail about the error when pusher registration fails. by @bmarty in https://github.com/element-hq/element-x-android/pull/4081
|
||||
* Update pull request template and CI automation by @bmarty in https://github.com/element-hq/element-x-android/pull/4037
|
||||
* Add a log function for handling complex values to the WebView client. by @Half-Shot in https://github.com/element-hq/element-x-android/pull/4098
|
||||
* design : CounterAtom by @ganfra in https://github.com/element-hq/element-x-android/pull/4108
|
||||
* Change sticker mimetype fallback to image by @surakin in https://github.com/element-hq/element-x-android/pull/4111
|
||||
* Dual licensing: AGPL + Element Commercial by @bmarty in https://github.com/element-hq/element-x-android/pull/4118
|
||||
* Replace the InfoListOrganism default bg color by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4091
|
||||
* Ignore dependency that are not third-party licenses to us. by @bmarty in https://github.com/element-hq/element-x-android/pull/4122
|
||||
* misc(send queue) : do not disable send queue when Network is Offline by @ganfra in https://github.com/element-hq/element-x-android/pull/4105
|
||||
* Remove or replace unnecessary `BackHandler` calls by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4148
|
||||
* Replace our firstIfSingle extension with singleOrNull from the Kotlin library by @bmarty in https://github.com/element-hq/element-x-android/pull/4184
|
||||
* Remove log. by @bmarty in https://github.com/element-hq/element-x-android/pull/4203
|
||||
* Remove unused types / code. by @bmarty in https://github.com/element-hq/element-x-android/pull/4185
|
||||
* Consider that the topic of a room has been removed when it's blank. by @bmarty in https://github.com/element-hq/element-x-android/pull/4209
|
||||
* CalVer: use 2 digits for the year and 2 digits for the month. by @bmarty in https://github.com/element-hq/element-x-android/pull/4192
|
||||
* Always display encryption badge by @bmarty in https://github.com/element-hq/element-x-android/pull/4219
|
||||
|
||||
## New Contributors
|
||||
* @Half-Shot made their first contribution in https://github.com/element-hq/element-x-android/pull/4098
|
||||
|
||||
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v0.7.6...v25.02.0
|
||||
|
||||
Changes in Element X v0.7.6 (2024-12-20)
|
||||
========================================
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
|
||||
import com.android.build.gradle.internal.tasks.factory.dependsOn
|
||||
import com.android.build.gradle.tasks.GenerateBuildConfig
|
||||
import config.BuildTimeConfig
|
||||
import extension.AssetCopyTask
|
||||
import extension.ComponentMergingStrategy
|
||||
import extension.GitBranchNameValueSource
|
||||
|
|
@ -43,11 +44,7 @@ android {
|
|||
namespace = "io.element.android.x"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = if (isEnterpriseBuild) {
|
||||
"io.element.enterprise"
|
||||
} else {
|
||||
"io.element.android.x"
|
||||
}
|
||||
applicationId = BuildTimeConfig.APPLICATION_ID
|
||||
targetSdk = Versions.TARGET_SDK
|
||||
versionCode = Versions.VERSION_CODE
|
||||
versionName = Versions.VERSION_NAME
|
||||
|
|
@ -97,11 +94,7 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
val baseAppName = if (isEnterpriseBuild) {
|
||||
"Element Enterprise"
|
||||
} else {
|
||||
"Element X"
|
||||
}
|
||||
val baseAppName = BuildTimeConfig.APPLICATION_NAME
|
||||
logger.warnInBox("Building $baseAppName")
|
||||
|
||||
buildTypes {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.activity.enableEdgeToEdge
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -26,6 +25,7 @@ import androidx.lifecycle.repeatOnLifecycle
|
|||
import com.bumble.appyx.core.integration.NodeHost
|
||||
import com.bumble.appyx.core.integrationpoint.NodeActivity
|
||||
import com.bumble.appyx.core.plugin.NodeReadyObserver
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||
import io.element.android.features.lockscreen.api.LockScreenLockState
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
|
|
@ -61,7 +61,10 @@ class MainActivity : NodeActivity() {
|
|||
@Composable
|
||||
private fun MainContent(appBindings: AppBindings) {
|
||||
val migrationState = appBindings.migrationEntryPoint().present()
|
||||
ElementThemeApp(appBindings.preferencesStore()) {
|
||||
ElementThemeApp(
|
||||
appPreferencesStore = appBindings.preferencesStore(),
|
||||
enterpriseService = appBindings.enterpriseService(),
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
|
||||
LocalUriHandler provides SafeUriHandler(this),
|
||||
|
|
@ -69,8 +72,8 @@ class MainActivity : NodeActivity() {
|
|||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
) {
|
||||
if (migrationState.migrationAction.isSuccess()) {
|
||||
MainNodeHost()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ package io.element.android.x.di
|
|||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import io.element.android.features.api.MigrationEntryPoint
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||
|
|
@ -35,4 +36,6 @@ interface AppBindings {
|
|||
fun lockScreenEntryPoint(): LockScreenEntryPoint
|
||||
|
||||
fun analyticsService(): AnalyticsService
|
||||
|
||||
fun enterpriseService(): EnterpriseService
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,23 +73,26 @@ object AppModule {
|
|||
@ApplicationContext context: Context,
|
||||
buildType: BuildType,
|
||||
enterpriseService: EnterpriseService,
|
||||
) = BuildMeta(
|
||||
isDebuggable = BuildConfig.DEBUG,
|
||||
buildType = buildType,
|
||||
applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name),
|
||||
productionApplicationName = ApplicationConfig.PRODUCTION_APPLICATION_NAME,
|
||||
desktopApplicationName = ApplicationConfig.DESKTOP_APPLICATION_NAME,
|
||||
applicationId = BuildConfig.APPLICATION_ID,
|
||||
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
|
||||
// TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
|
||||
lowPrivacyLoggingEnabled = false,
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
versionCode = context.getVersionCodeFromManifest(),
|
||||
gitRevision = BuildConfig.GIT_REVISION,
|
||||
gitBranchName = BuildConfig.GIT_BRANCH_NAME,
|
||||
flavorDescription = BuildConfig.FLAVOR_DESCRIPTION,
|
||||
flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION,
|
||||
)
|
||||
): BuildMeta {
|
||||
val applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name)
|
||||
return BuildMeta(
|
||||
isDebuggable = BuildConfig.DEBUG,
|
||||
buildType = buildType,
|
||||
applicationName = applicationName,
|
||||
productionApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME,
|
||||
desktopApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME,
|
||||
applicationId = BuildConfig.APPLICATION_ID,
|
||||
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
|
||||
// TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
|
||||
lowPrivacyLoggingEnabled = false,
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
versionCode = context.getVersionCodeFromManifest(),
|
||||
gitRevision = BuildConfig.GIT_REVISION,
|
||||
gitBranchName = BuildConfig.GIT_BRANCH_NAME,
|
||||
flavorDescription = BuildConfig.FLAVOR_DESCRIPTION,
|
||||
flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<locale android:name="ru"/>
|
||||
<locale android:name="sk"/>
|
||||
<locale android:name="sv"/>
|
||||
<locale android:name="tr"/>
|
||||
<locale android:name="uk"/>
|
||||
<locale android:name="uz"/>
|
||||
<locale android:name="zh-CN"/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Ref: https://developer.android.com/training/articles/security-config.html -->
|
||||
<!-- By default, do not allow clearText traffic -->
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates
|
||||
src="user"
|
||||
tools:ignore="AcceptsUserCertificates" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- Allow clearText traffic on some specified host -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
|
|
@ -24,12 +31,4 @@
|
|||
<domain includeSubdomains="true">lan</domain>
|
||||
<domain includeSubdomains="true">localdomain</domain>
|
||||
</domain-config>
|
||||
|
||||
<debug-overrides>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</debug-overrides>
|
||||
|
||||
</network-security-config>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,6 @@ package io.element.android.appconfig
|
|||
object AuthenticationConfig {
|
||||
const val MATRIX_ORG_URL = "https://matrix.org"
|
||||
|
||||
/**
|
||||
* Default homeserver url to sign in with, unless the user selects a different one.
|
||||
*/
|
||||
const val DEFAULT_HOMESERVER_URL = MATRIX_ORG_URL
|
||||
|
||||
/**
|
||||
* URL with some docs that explain what's sliding sync and how to add it to your home server.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.bumble.appyx.core.composable.PermanentChild
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
|
|
@ -52,8 +50,6 @@ import io.element.android.features.ftue.api.FtueEntryPoint
|
|||
import io.element.android.features.ftue.api.state.FtueService
|
||||
import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.logout.api.LogoutEntryPoint
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
|
||||
|
|
@ -77,18 +73,12 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
|||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
|
@ -107,7 +97,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
private val userProfileEntryPoint: UserProfileEntryPoint,
|
||||
private val ftueEntryPoint: FtueEntryPoint,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val ftueService: FtueService,
|
||||
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
||||
private val shareEntryPoint: ShareEntryPoint,
|
||||
|
|
@ -115,7 +104,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
private val sendingQueue: SendQueues,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
|
|
@ -133,7 +121,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
fun onOpenBugReport()
|
||||
}
|
||||
|
||||
private val syncService = matrixClient.syncService()
|
||||
private val loggedInFlowProcessor = LoggedInEventProcessor(
|
||||
snackbarDispatcher,
|
||||
matrixClient.roomMembershipObserver(),
|
||||
|
|
@ -147,6 +134,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
|
||||
|
|
@ -165,12 +153,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
.launchIn(lifecycleScope)
|
||||
},
|
||||
onStop = {
|
||||
coroutineScope.launch {
|
||||
// Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
|
||||
syncService.stopSync()
|
||||
}
|
||||
},
|
||||
onDestroy = {
|
||||
appNavigationStateService.onLeavingSpace(id)
|
||||
appNavigationStateService.onLeavingSession(id)
|
||||
|
|
@ -178,7 +160,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
matrixClient.sessionVerificationService().setListener(null)
|
||||
}
|
||||
)
|
||||
observeSyncStateAndNetworkStatus()
|
||||
setupSendingQueue()
|
||||
}
|
||||
|
||||
|
|
@ -186,31 +167,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
sendingQueue.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeSyncStateAndNetworkStatus() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
combine(
|
||||
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
|
||||
syncService.syncState.debounce(100),
|
||||
networkMonitor.connectivity
|
||||
) { syncState, networkStatus ->
|
||||
Pair(syncState, networkStatus)
|
||||
}
|
||||
.onStart {
|
||||
// Temporary fix to ensure that the sync is started even if the networkStatus is offline.
|
||||
syncService.startSync()
|
||||
}
|
||||
.collect { (syncState, networkStatus) ->
|
||||
Timber.d("Sync state: $syncState, network status: $networkStatus")
|
||||
if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {
|
||||
syncService.startSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Placeholder : NavTarget
|
||||
|
|
@ -401,8 +357,8 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
NavTarget.CreateRoom -> {
|
||||
val callback = object : CreateRoomEntryPoint.Callback {
|
||||
override fun onSuccess(roomId: RoomId) {
|
||||
backstack.replace(NavTarget.Room(roomId.toRoomIdOrAlias()))
|
||||
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
|
||||
backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -462,9 +418,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
logoutEntryPoint.nodeBuilder(this, buildContext)
|
||||
.onSuccessfulLogoutPendingAction {
|
||||
enableNativeSlidingSyncUseCase()
|
||||
}
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.appnav.di.MatrixClientsHolder
|
||||
import io.element.android.appnav.di.MatrixSessionCache
|
||||
import io.element.android.appnav.intent.IntentResolver
|
||||
import io.element.android.appnav.intent.ResolvedIntent
|
||||
import io.element.android.appnav.root.RootNavStateFlowFactory
|
||||
|
|
@ -62,7 +62,7 @@ class RootFlowNode @AssistedInject constructor(
|
|||
@Assisted plugins: List<Plugin>,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val navStateFlowFactory: RootNavStateFlowFactory,
|
||||
private val matrixClientsHolder: MatrixClientsHolder,
|
||||
private val matrixSessionCache: MatrixSessionCache,
|
||||
private val presenter: RootPresenter,
|
||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||
private val viewFolderEntryPoint: ViewFolderEntryPoint,
|
||||
|
|
@ -78,14 +78,14 @@ class RootFlowNode @AssistedInject constructor(
|
|||
plugins = plugins
|
||||
) {
|
||||
override fun onBuilt() {
|
||||
matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
|
||||
matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
|
||||
super.onBuilt()
|
||||
observeNavState()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(state: MutableSavedStateMap) {
|
||||
super.onSaveInstanceState(state)
|
||||
matrixClientsHolder.saveIntoSavedState(state)
|
||||
matrixSessionCache.saveIntoSavedState(state)
|
||||
navStateFlowFactory.saveIntoSavedState(state)
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ class RootFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private fun switchToNotLoggedInFlow() {
|
||||
matrixClientsHolder.removeAll()
|
||||
matrixSessionCache.removeAll()
|
||||
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ class RootFlowNode @AssistedInject constructor(
|
|||
onFailure: () -> Unit,
|
||||
onSuccess: (SessionId) -> Unit,
|
||||
) {
|
||||
matrixClientsHolder.getOrRestore(sessionId)
|
||||
matrixSessionCache.getOrRestore(sessionId)
|
||||
.onSuccess {
|
||||
Timber.v("Succeed to restore session $sessionId")
|
||||
onSuccess(sessionId)
|
||||
|
|
@ -200,7 +200,7 @@ class RootFlowNode @AssistedInject constructor(
|
|||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
is NavTarget.LoggedInFlow -> {
|
||||
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
|
||||
val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
|
||||
Timber.w("Couldn't find any session, go through SplashScreen")
|
||||
}
|
||||
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
package io.element.android.appnav.di
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.bumble.appyx.core.state.MutableSavedStateMap
|
||||
import com.bumble.appyx.core.state.SavedStateMap
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
|
|
@ -25,45 +26,61 @@ import javax.inject.Inject
|
|||
|
||||
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
|
||||
|
||||
/**
|
||||
* In-memory cache for logged in Matrix sessions.
|
||||
*
|
||||
* This component contains both the [MatrixClient] and the [SyncOrchestrator] for each session.
|
||||
*/
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class MatrixClientsHolder @Inject constructor(
|
||||
class MatrixSessionCache @Inject constructor(
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val syncOrchestratorFactory: SyncOrchestrator.Factory,
|
||||
) : MatrixClientProvider {
|
||||
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
|
||||
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
|
||||
private val restoreMutex = Mutex()
|
||||
|
||||
init {
|
||||
authenticationService.listenToNewMatrixClients { matrixClient ->
|
||||
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
|
||||
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
|
||||
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
|
||||
matrixClient = matrixClient,
|
||||
syncOrchestrator = syncOrchestrator,
|
||||
)
|
||||
syncOrchestrator.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
sessionIdsToMatrixClient.clear()
|
||||
sessionIdsToMatrixSession.clear()
|
||||
}
|
||||
|
||||
fun remove(sessionId: SessionId) {
|
||||
sessionIdsToMatrixClient.remove(sessionId)
|
||||
sessionIdsToMatrixSession.remove(sessionId)
|
||||
}
|
||||
|
||||
override fun getOrNull(sessionId: SessionId): MatrixClient? {
|
||||
return sessionIdsToMatrixClient[sessionId]
|
||||
return sessionIdsToMatrixSession[sessionId]?.matrixClient
|
||||
}
|
||||
|
||||
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> {
|
||||
return restoreMutex.withLock {
|
||||
when (val matrixClient = getOrNull(sessionId)) {
|
||||
when (val cached = getOrNull(sessionId)) {
|
||||
null -> restore(sessionId)
|
||||
else -> Result.success(matrixClient)
|
||||
else -> Result.success(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun getSyncOrchestrator(sessionId: SessionId): SyncOrchestrator? {
|
||||
return sessionIdsToMatrixSession[sessionId]?.syncOrchestrator
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun restoreWithSavedState(state: SavedStateMap?) {
|
||||
Timber.d("Restore state")
|
||||
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) {
|
||||
if (state == null || sessionIdsToMatrixSession.isNotEmpty()) {
|
||||
Timber.w("Restore with non-empty map")
|
||||
return
|
||||
}
|
||||
|
|
@ -79,7 +96,7 @@ class MatrixClientsHolder @Inject constructor(
|
|||
}
|
||||
|
||||
fun saveIntoSavedState(state: MutableSavedStateMap) {
|
||||
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
|
||||
val sessionKeys = sessionIdsToMatrixSession.keys.toTypedArray()
|
||||
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
|
||||
state[SAVE_INSTANCE_KEY] = sessionKeys
|
||||
}
|
||||
|
|
@ -88,10 +105,20 @@ class MatrixClientsHolder @Inject constructor(
|
|||
Timber.d("Restore matrix session: $sessionId")
|
||||
return authenticationService.restoreSession(sessionId)
|
||||
.onSuccess { matrixClient ->
|
||||
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
|
||||
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
|
||||
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
|
||||
matrixClient = matrixClient,
|
||||
syncOrchestrator = syncOrchestrator,
|
||||
)
|
||||
syncOrchestrator.start()
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "Fail to restore session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class InMemoryMatrixSession(
|
||||
val matrixClient: MatrixClient,
|
||||
val syncOrchestrator: SyncOrchestrator,
|
||||
)
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.appnav.di
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.coroutine.childScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class SyncOrchestrator @AssistedInject constructor(
|
||||
@Assisted matrixClient: MatrixClient,
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(matrixClient: MatrixClient): SyncOrchestrator
|
||||
}
|
||||
|
||||
private val syncService = matrixClient.syncService()
|
||||
|
||||
private val tag = "SyncOrchestrator"
|
||||
|
||||
private val coroutineScope = matrixClient.sessionCoroutineScope.childScope(dispatchers.io, tag)
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Starting observing the app state and network state to start/stop the sync service.
|
||||
*
|
||||
* Before observing the state, a first attempt at starting the sync service will happen if it's not already running.
|
||||
*/
|
||||
fun start() {
|
||||
if (!started.compareAndSet(false, true)) {
|
||||
Timber.tag(tag).d("already started, exiting early")
|
||||
return
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
// Perform an initial sync if the sync service is not running, to check whether the homeserver is accessible
|
||||
// Otherwise, if the device is offline the sync service will never start and the SyncState will be Idle, not Offline
|
||||
Timber.tag(tag).d("performing initial sync attempt")
|
||||
syncService.startSync()
|
||||
|
||||
// Wait until the sync service is not idle, either it will be running or in error/offline state
|
||||
syncService.syncState.first { it != SyncState.Idle }
|
||||
|
||||
observeStates()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun observeStates() = coroutineScope.launch {
|
||||
Timber.tag(tag).d("start observing the app and network state")
|
||||
|
||||
combine(
|
||||
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
|
||||
syncService.syncState.debounce(100.milliseconds),
|
||||
networkMonitor.connectivity,
|
||||
appForegroundStateService.isInForeground,
|
||||
appForegroundStateService.isInCall,
|
||||
appForegroundStateService.isSyncingNotificationEvent,
|
||||
) { syncState, networkState, isInForeground, isInCall, isSyncingNotificationEvent ->
|
||||
val isAppActive = isInForeground || isInCall || isSyncingNotificationEvent
|
||||
val isNetworkAvailable = networkState == NetworkStatus.Connected
|
||||
|
||||
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
|
||||
if (syncState == SyncState.Running && !isAppActive) {
|
||||
SyncStateAction.StopSync
|
||||
} else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
|
||||
SyncStateAction.StartSync
|
||||
} else {
|
||||
SyncStateAction.NoOp
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce { action ->
|
||||
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
|
||||
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
|
||||
}
|
||||
.onCompletion {
|
||||
Timber.tag(tag).d("has been stopped")
|
||||
}
|
||||
.collect { action ->
|
||||
when (action) {
|
||||
SyncStateAction.StartSync -> {
|
||||
syncService.startSync()
|
||||
}
|
||||
SyncStateAction.StopSync -> {
|
||||
syncService.stopSync()
|
||||
}
|
||||
SyncStateAction.NoOp -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SyncStateAction {
|
||||
StartSync,
|
||||
StopSync,
|
||||
NoOp,
|
||||
}
|
||||
|
|
@ -22,19 +22,21 @@ import im.vector.app.features.analytics.plan.UserProperties
|
|||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.pushproviders.api.RegistrationFailure
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
|
@ -51,7 +53,7 @@ class LoggedInPresenter @Inject constructor(
|
|||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<LoggedInState> {
|
||||
@Composable
|
||||
override fun present(): LoggedInState {
|
||||
|
|
@ -60,6 +62,7 @@ class LoggedInPresenter @Inject constructor(
|
|||
pushService.ignoreRegistrationError(matrixClient.sessionId)
|
||||
}.collectAsState(initial = false)
|
||||
val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) }
|
||||
LaunchedEffect(Unit) { preloadAccountManagementUrl() }
|
||||
LaunchedEffect(Unit) {
|
||||
sessionVerificationService.sessionVerifiedStatus
|
||||
.onEach { sessionVerifiedStatus ->
|
||||
|
|
@ -103,12 +106,10 @@ class LoggedInPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
|
||||
forceNativeSlidingSyncMigration = matrixClient.forceNativeSlidingSyncMigration().getOrDefault(false)
|
||||
forceNativeSlidingSyncMigration = matrixClient.needsForcedNativeSlidingSyncMigration().getOrDefault(false)
|
||||
}
|
||||
LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
|
||||
// Enable native sliding sync if it wasn't already the case
|
||||
enableNativeSlidingSyncUseCase()
|
||||
// Then force the logout
|
||||
// Force the logout since Native Sliding Sync is already enforced by the SDK
|
||||
matrixClient.logout(userInitiated = true, ignoreSdkError = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -119,20 +120,15 @@ class LoggedInPresenter @Inject constructor(
|
|||
pusherRegistrationState = pusherRegistrationState.value,
|
||||
ignoreRegistrationError = ignoreRegistrationError,
|
||||
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
||||
appName = buildMeta.applicationName,
|
||||
eventSink = ::handleEvent
|
||||
)
|
||||
}
|
||||
|
||||
// Force the user to log out if they were using the proxy sliding sync and it's no longer available, but native sliding sync is.
|
||||
private suspend fun MatrixClient.forceNativeSlidingSyncMigration(): Result<Boolean> = runCatching {
|
||||
// Force the user to log out if they were using the proxy sliding sync as it's no longer supported by the SDK
|
||||
private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatching {
|
||||
val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
|
||||
if (currentSlidingSyncVersion == SlidingSyncVersion.Proxy) {
|
||||
val availableSlidingSyncVersions = availableSlidingSyncVersions().getOrThrow()
|
||||
availableSlidingSyncVersions.contains(SlidingSyncVersion.Native) &&
|
||||
!availableSlidingSyncVersions.contains(SlidingSyncVersion.Proxy)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
currentSlidingSyncVersion == SlidingSyncVersion.Proxy
|
||||
}
|
||||
|
||||
private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState<AsyncData<Unit>>) {
|
||||
|
|
@ -209,4 +205,9 @@ class LoggedInPresenter @Inject constructor(
|
|||
analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState))
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.preloadAccountManagementUrl() = launch {
|
||||
matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
|
||||
matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@ data class LoggedInState(
|
|||
val pusherRegistrationState: AsyncData<Unit>,
|
||||
val ignoreRegistrationError: Boolean,
|
||||
val forceNativeSlidingSyncMigration: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (LoggedInEvents) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@ fun aLoggedInState(
|
|||
showSyncSpinner: Boolean = false,
|
||||
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||
forceNativeSlidingSyncMigration: Boolean = false,
|
||||
appName: String = "Element X",
|
||||
) = LoggedInState(
|
||||
showSyncSpinner = showSyncSpinner,
|
||||
pusherRegistrationState = pusherRegistrationState,
|
||||
ignoreRegistrationError = false,
|
||||
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
|
||||
appName = appName,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -73,9 +73,12 @@ fun LoggedInView(
|
|||
|
||||
// Set the force migration dialog here so it's always displayed over every screen
|
||||
if (state.forceNativeSlidingSyncMigration) {
|
||||
ForceNativeSlidingSyncMigrationDialog(onSubmit = {
|
||||
state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
|
||||
})
|
||||
ForceNativeSlidingSyncMigrationDialog(
|
||||
appName = state.appName,
|
||||
onSubmit = {
|
||||
state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,11 +101,12 @@ private fun Throwable.getReason(): String? {
|
|||
|
||||
@Composable
|
||||
private fun ForceNativeSlidingSyncMigrationDialog(
|
||||
appName: String,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
ErrorDialog(
|
||||
title = null,
|
||||
content = stringResource(R.string.banner_migrate_to_native_sliding_sync_force_logout_title),
|
||||
content = stringResource(R.string.banner_migrate_to_native_sliding_sync_app_force_logout_title, appName),
|
||||
submitText = stringResource(R.string.banner_migrate_to_native_sliding_sync_action),
|
||||
onSubmit = onSubmit,
|
||||
canDismiss = false,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class SendQueues @Inject constructor(
|
|||
) {
|
||||
/**
|
||||
* Launches the send queues retry mechanism in the given [coroutineScope].
|
||||
* Makes sure to re-enable all send queues when the network status is [NetworkStatus.Online].
|
||||
* Makes sure to re-enable all send queues when the network status is [NetworkStatus.Connected].
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
fun launchIn(coroutineScope: CoroutineScope) {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ package io.element.android.appnav.room.joined
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
|
|
@ -56,7 +54,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
|||
roomComponentFactory: RoomComponentFactory,
|
||||
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = when (val input = plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
|
||||
initialElement = when (val input = plugins.filterIsInstance<Inputs>().first().initialElement) {
|
||||
is RoomNavigationTarget.Messages -> NavTarget.Messages(input.focusedEventId)
|
||||
RoomNavigationTarget.Details -> NavTarget.RoomDetails
|
||||
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings
|
||||
|
|
@ -197,16 +195,6 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
// Rely on the View Lifecycle in addition to the Node Lifecycle,
|
||||
// because this node enters 'onDestroy' before his children, so it can leads to
|
||||
// using the room in a child node where it's already closed.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
|
||||
inputs.room.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
BackstackView()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ package io.element.android.appnav.root
|
|||
|
||||
import com.bumble.appyx.core.state.MutableSavedStateMap
|
||||
import com.bumble.appyx.core.state.SavedStateMap
|
||||
import io.element.android.appnav.di.MatrixClientsHolder
|
||||
import io.element.android.appnav.di.MatrixSessionCache
|
||||
import io.element.android.features.login.api.LoginUserStory
|
||||
import io.element.android.features.preferences.api.CacheService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
|
|
@ -31,7 +31,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact
|
|||
class RootNavStateFlowFactory @Inject constructor(
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val cacheService: CacheService,
|
||||
private val matrixClientsHolder: MatrixClientsHolder,
|
||||
private val matrixSessionCache: MatrixSessionCache,
|
||||
private val imageLoaderHolder: ImageLoaderHolder,
|
||||
private val loginUserStory: LoginUserStory,
|
||||
private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory,
|
||||
|
|
@ -63,7 +63,7 @@ class RootNavStateFlowFactory @Inject constructor(
|
|||
val initialCacheIndex = savedStateMap.getCacheIndexOrDefault()
|
||||
return cacheService.clearedCacheEventFlow
|
||||
.onEach { sessionId ->
|
||||
matrixClientsHolder.remove(sessionId)
|
||||
matrixSessionCache.remove(sessionId)
|
||||
// Ensure image loader will be recreated with the new MatrixClient
|
||||
imageLoaderHolder.remove(sessionId)
|
||||
// Also remove cached value for SessionPreferencesStore
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásit se a upgradovat"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste."</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Abmelden und aktualisieren"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$sunterstützt das alte Protokoll nicht mehr. Bitte melden Sie sich ab und wieder an, um die App weiter nutzen zu können."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Dein Homeserver unterstützt das alte Protokoll nicht mehr. Bitte logge dich aus und melde dich wieder an, um die App weiter zu nutzen."</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Logi välja ja uuenda"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s enam ei toeta vana protokolli. Kui soovid rakendust edasi kasutada, siis logi korraks temast välja ning seejärel tagasi."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Sinu koduserver enam ei toeta vana protokolli. Jätkamaks rakenduse kasutamist palun logi välja ning seejärel tagasi."</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Déconnecter et mettre à niveau"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s ne prend plus en charge l’ancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser l’application."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Votre serveur d’accueil ne prend plus en charge l’ancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser l’application."</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Выйти и обновить"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s больше не поддерживает старый протокол. Пожалуйста, выйдите из системы и войдите снова, чтобы продолжить использование приложения."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш домашний сервер больше не поддерживает старый протокол. Пожалуйста, выйдите и войдите в свою учётную запись снова, чтобы продолжить использование приложения."</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásiť sa a aktualizovať"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s už nepodporuje starý protokol. Odhláste sa a znova prihláste, aby ste mohli pokračovať v používaní aplikácie."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server už nepodporuje starý protokol. Ak chcete pokračovať v používaní aplikácie, odhláste sa a znova sa prihláste."</string>
|
||||
</resources>
|
||||
|
|
|
|||
5
appnav/src/main/res/values-tr/translations.xml
Normal file
5
appnav/src/main/res/values-tr/translations.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Çıkış Yap ve Yükselt"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ana sunucunuz artık eski protokolü desteklemiyor. Lütfen oturumu kapatın ve uygulamayı kullanmaya devam etmek için tekrar oturum açın."</string>
|
||||
</resources>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Log Out & Upgrade"</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s no longer supports the old protocol. Please log out and log back in to continue using the app."</string>
|
||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.appnav
|
||||
|
||||
import io.element.android.appnav.di.SyncOrchestrator
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SyncOrchestratorTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `when the sync wasn't running before, an initial sync will take place, even with no network`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
)
|
||||
|
||||
// We start observing with an initial sync
|
||||
syncOrchestrator.start()
|
||||
|
||||
// Advance the time just enough to make sure the initial sync has run
|
||||
advanceTimeBy(1.milliseconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync wasn't running before, an initial sync will take place`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
)
|
||||
|
||||
// We start observing with an initial sync
|
||||
syncOrchestrator.start()
|
||||
|
||||
// Advance the time just enough to make sure the initial sync has run
|
||||
advanceTimeBy(1.milliseconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// If we wait for a while, the sync will not be started again by the observer since it's already running
|
||||
advanceTimeBy(10.seconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the app goes to background and the sync was running, it will be stopped after a delay`() = runTest {
|
||||
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
|
||||
stopSyncLambda = stopSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// Advance the time to make sure the orchestrator has had time to start processing the inputs
|
||||
advanceTimeBy(100.milliseconds)
|
||||
|
||||
// Stop sync was never called
|
||||
stopSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// Now we send the app to background
|
||||
appForegroundStateService.isInForeground.value = false
|
||||
|
||||
// Stop sync will be called after some delay
|
||||
stopSyncRecorder.assertions().isNeverCalled()
|
||||
advanceTimeBy(10.seconds)
|
||||
stopSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the app state changes several times in a short while, stop sync is only called once`() = runTest {
|
||||
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
|
||||
stopSyncLambda = stopSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// Advance the time to make sure the orchestrator has had time to start processing the inputs
|
||||
advanceTimeBy(100.milliseconds)
|
||||
|
||||
// Stop sync was never called
|
||||
stopSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// Now we send the app to background
|
||||
appForegroundStateService.isInForeground.value = false
|
||||
|
||||
// Ensure the stop action wasn't called yet
|
||||
stopSyncRecorder.assertions().isNeverCalled()
|
||||
advanceTimeBy(1.seconds)
|
||||
appForegroundStateService.isInForeground.value = true
|
||||
advanceTimeBy(1.seconds)
|
||||
|
||||
// Ensure the stop action wasn't called yet either, since we didn't give it enough time to emit after the expected delay
|
||||
stopSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// Now change it again and wait for enough time
|
||||
appForegroundStateService.isInForeground.value = false
|
||||
advanceTimeBy(4.seconds)
|
||||
|
||||
// And confirm it's now called
|
||||
stopSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the app was in background and we receive a notification, a sync will be started then stopped`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
stopSyncLambda = stopSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = false,
|
||||
initialIsSyncingNotificationEventValue = false,
|
||||
)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// Advance the time to make sure the orchestrator has had time to start processing the inputs
|
||||
advanceTimeBy(100.milliseconds)
|
||||
|
||||
// Start sync was never called
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// Now we receive a notification and need to sync
|
||||
appForegroundStateService.updateIsSyncingNotificationEvent(true)
|
||||
|
||||
// Start sync will be called shortly after
|
||||
advanceTimeBy(1.milliseconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// If the sync is running and we mark the notification sync as no longer necessary, the sync stops after a delay
|
||||
syncService.emitSyncState(SyncState.Running)
|
||||
appForegroundStateService.updateIsSyncingNotificationEvent(false)
|
||||
|
||||
advanceTimeBy(10.seconds)
|
||||
stopSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the app was in background and we join a call, a sync will be started`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
stopSyncLambda = stopSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = false,
|
||||
initialIsSyncingNotificationEventValue = false,
|
||||
)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// Advance the time to make sure the orchestrator has had time to start processing the inputs
|
||||
advanceTimeBy(100.milliseconds)
|
||||
|
||||
// Start sync was never called
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// Now we join a call
|
||||
appForegroundStateService.updateIsInCallState(true)
|
||||
|
||||
// Start sync will be called shortly after
|
||||
advanceTimeBy(1.milliseconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// If the sync is running and we mark the in-call state as false, the sync stops after a delay
|
||||
syncService.emitSyncState(SyncState.Running)
|
||||
appForegroundStateService.updateIsInCallState(false)
|
||||
|
||||
advanceTimeBy(10.seconds)
|
||||
stopSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the app is in foreground, we sync for a notification and a call is ongoing, the sync will only stop when all conditions are false`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
stopSyncLambda = stopSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = true,
|
||||
initialIsSyncingNotificationEventValue = true,
|
||||
initialIsInCallValue = true,
|
||||
)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// Advance the time to make sure the orchestrator has had time to start processing the inputs
|
||||
advanceTimeBy(100.milliseconds)
|
||||
|
||||
// Start sync was never called
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// We send the app to background, it's still syncing
|
||||
appForegroundStateService.givenIsInForeground(false)
|
||||
advanceTimeBy(10.seconds)
|
||||
stopSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// We stop the notification sync, it's still syncing
|
||||
appForegroundStateService.updateIsSyncingNotificationEvent(false)
|
||||
advanceTimeBy(10.seconds)
|
||||
stopSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// We set the in-call state to false, now it stops syncing after a delay
|
||||
appForegroundStateService.updateIsInCallState(false)
|
||||
advanceTimeBy(10.seconds)
|
||||
stopSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `if the sync was running, it's set to be stopped but something triggers a sync again, the sync is not stopped`() = runTest {
|
||||
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
|
||||
stopSyncLambda = stopSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = true,
|
||||
initialIsSyncingNotificationEventValue = false,
|
||||
initialIsInCallValue = false,
|
||||
)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// Advance the time to make sure the orchestrator has had time to start processing the inputs
|
||||
advanceTimeBy(100.milliseconds)
|
||||
|
||||
// This will set the sync to stop
|
||||
appForegroundStateService.givenIsInForeground(false)
|
||||
|
||||
// But if we reset it quickly before the stop sync takes place, the sync is not stopped
|
||||
advanceTimeBy(2.seconds)
|
||||
appForegroundStateService.givenIsInForeground(true)
|
||||
|
||||
advanceTimeBy(10.seconds)
|
||||
stopSyncRecorder.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when network is offline, sync service should not start`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
syncService = syncService,
|
||||
networkMonitor = networkMonitor,
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// This should still not trigger a sync, since there is no network
|
||||
advanceTimeBy(10.seconds)
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
private fun TestScope.createSyncOrchestrator(
|
||||
syncService: FakeSyncService = FakeSyncService(),
|
||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
|
||||
) = SyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService, sessionCoroutineScope = backgroundScope),
|
||||
networkMonitor = networkMonitor,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.appnav.di
|
||||
|
||||
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class MatrixClientsHolderTest {
|
||||
@Test
|
||||
fun `test getOrNull`() {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getOrRestore`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
val fakeMatrixClient = FakeMatrixClient()
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
// Do it again to hit the cache
|
||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test remove`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
val fakeMatrixClient = FakeMatrixClient()
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
// Remove
|
||||
matrixClientsHolder.remove(A_SESSION_ID)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test remove all`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
val fakeMatrixClient = FakeMatrixClient()
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
// Remove all
|
||||
matrixClientsHolder.removeAll()
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test save and restore`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
val fakeMatrixClient = FakeMatrixClient()
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
matrixClientsHolder.getOrRestore(A_SESSION_ID)
|
||||
val savedStateMap = MutableSavedStateMapImpl { true }
|
||||
matrixClientsHolder.saveIntoSavedState(savedStateMap)
|
||||
assertThat(savedStateMap.size).isEqualTo(1)
|
||||
// Test Restore with non-empty map
|
||||
matrixClientsHolder.restoreWithSavedState(savedStateMap)
|
||||
// Empty the map
|
||||
matrixClientsHolder.removeAll()
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
// Restore again
|
||||
matrixClientsHolder.restoreWithSavedState(savedStateMap)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
|
||||
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID))
|
||||
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
|
||||
|
||||
assertThat(loginSucceeded.isSuccess).isTrue()
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNotNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.appnav.di
|
||||
|
||||
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class MatrixSessionCacheTest {
|
||||
@Test
|
||||
fun `test getOrNull`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `test getSyncOrchestratorOrNull`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
|
||||
|
||||
// With no matrix client there is no sync orchestrator
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNull()
|
||||
|
||||
// But as soon as we receive a client, we can get the sync orchestrator
|
||||
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getOrRestore`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
|
||||
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
// Do it again to hit the cache
|
||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test remove`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
|
||||
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
// Remove
|
||||
matrixSessionCache.remove(A_SESSION_ID)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test remove all`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
|
||||
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
// Remove all
|
||||
matrixSessionCache.removeAll()
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test save and restore`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
|
||||
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID)
|
||||
val savedStateMap = MutableSavedStateMapImpl { true }
|
||||
matrixSessionCache.saveIntoSavedState(savedStateMap)
|
||||
assertThat(savedStateMap.size).isEqualTo(1)
|
||||
// Test Restore with non-empty map
|
||||
matrixSessionCache.restoreWithSavedState(savedStateMap)
|
||||
// Empty the map
|
||||
matrixSessionCache.removeAll()
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
// Restore again
|
||||
matrixSessionCache.restoreWithSavedState(savedStateMap)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
|
||||
val fakeAuthenticationService = FakeMatrixAuthenticationService()
|
||||
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
|
||||
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID, sessionCoroutineScope = backgroundScope))
|
||||
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
|
||||
|
||||
assertThat(loginSucceeded.isSuccess).isTrue()
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNotNull()
|
||||
}
|
||||
|
||||
private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory {
|
||||
override fun create(matrixClient: MatrixClient): SyncOrchestrator {
|
||||
return SyncOrchestrator(
|
||||
matrixClient,
|
||||
appForegroundStateService = FakeAppForegroundStateService(),
|
||||
networkMonitor = FakeNetworkMonitor(),
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,19 +5,20 @@
|
|||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.appnav.loggedin
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
|
||||
import im.vector.app.features.analytics.plan.UserProperties
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
|
|
@ -26,12 +27,11 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
|
|||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.push.test.FakePushService
|
||||
import io.element.android.libraries.pushproviders.api.Distributor
|
||||
|
|
@ -45,9 +45,8 @@ import io.element.android.tests.testutils.lambda.any
|
|||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
|
|
@ -59,10 +58,7 @@ class LoggedInPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createLoggedInPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
createLoggedInPresenter().test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showSyncSpinner).isFalse()
|
||||
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
|
||||
|
|
@ -70,13 +66,32 @@ class LoggedInPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ensure that account urls are preloaded`() = runTest {
|
||||
val accountManagementUrlResult = lambdaRecorder<AccountManagementAction?, Result<String?>> { Result.success("aUrl") }
|
||||
val matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = accountManagementUrlResult,
|
||||
)
|
||||
createLoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
).test {
|
||||
awaitItem()
|
||||
advanceUntilIdle()
|
||||
accountManagementUrlResult.assertions().isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value(AccountManagementAction.Profile)),
|
||||
listOf(value(AccountManagementAction.SessionsList)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show sync spinner`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createLoggedInPresenter(roomListService, SyncState.Running)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
createLoggedInPresenter(
|
||||
syncState = SyncState.Running,
|
||||
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showSyncSpinner).isFalse()
|
||||
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
|
||||
|
|
@ -92,18 +107,19 @@ class LoggedInPresenterTest {
|
|||
val roomListService = FakeRoomListService()
|
||||
val verificationService = FakeSessionVerificationService()
|
||||
val encryptionService = FakeEncryptionService()
|
||||
val presenter = LoggedInPresenter(
|
||||
matrixClient = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService),
|
||||
val buildMeta = aBuildMeta()
|
||||
LoggedInPresenter(
|
||||
matrixClient = FakeMatrixClient(
|
||||
roomListService = roomListService,
|
||||
encryptionService = encryptionService,
|
||||
),
|
||||
syncService = FakeSyncService(initialSyncState = SyncState.Running),
|
||||
pushService = FakePushService(),
|
||||
sessionVerificationService = verificationService,
|
||||
analyticsService = analyticsService,
|
||||
encryptionService = encryptionService,
|
||||
enableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(InMemoryAppPreferencesStore(), this),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
buildMeta = buildMeta,
|
||||
).test {
|
||||
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
|
||||
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
|
||||
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
|
|
@ -129,13 +145,10 @@ class LoggedInPresenterTest {
|
|||
val verificationService = FakeSessionVerificationService(
|
||||
initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = verificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.errorOrNull())
|
||||
.isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java)
|
||||
|
|
@ -155,13 +168,13 @@ class LoggedInPresenterTest {
|
|||
val pushService = createFakePushService(
|
||||
registerWithLambda = lambda,
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
|
||||
lambda.assertions()
|
||||
|
|
@ -188,13 +201,13 @@ class LoggedInPresenterTest {
|
|||
val pushService = createFakePushService(
|
||||
registerWithLambda = lambda,
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
|
||||
lambda.assertions()
|
||||
|
|
@ -233,13 +246,13 @@ class LoggedInPresenterTest {
|
|||
currentPushProvider = { pushProvider },
|
||||
registerWithLambda = lambda,
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
|
||||
lambda.assertions()
|
||||
|
|
@ -277,13 +290,13 @@ class LoggedInPresenterTest {
|
|||
currentPushProvider = { pushProvider },
|
||||
registerWithLambda = lambda,
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
|
||||
lambda.assertions()
|
||||
|
|
@ -317,13 +330,10 @@ class LoggedInPresenterTest {
|
|||
currentPushProvider = { pushProvider },
|
||||
registerWithLambda = lambda,
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.errorOrNull())
|
||||
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
|
||||
|
|
@ -345,13 +355,10 @@ class LoggedInPresenterTest {
|
|||
registerWithLambda = lambda,
|
||||
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.errorOrNull())
|
||||
.isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java)
|
||||
|
|
@ -394,13 +401,10 @@ class LoggedInPresenterTest {
|
|||
registerWithLambda = lambda,
|
||||
selectPushProviderLambda = selectPushProviderLambda,
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.errorOrNull())
|
||||
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
|
||||
|
|
@ -445,13 +449,13 @@ class LoggedInPresenterTest {
|
|||
pushProvider1 = pushProvider1,
|
||||
registerWithLambda = lambda,
|
||||
)
|
||||
val presenter = createLoggedInPresenter(
|
||||
createLoggedInPresenter(
|
||||
pushService = pushService,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
).test {
|
||||
val finalState = awaitFirstItem()
|
||||
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
|
||||
lambda.assertions().isCalledOnce()
|
||||
|
|
@ -505,10 +509,9 @@ class LoggedInPresenterTest {
|
|||
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
|
||||
availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
|
||||
)
|
||||
val presenter = createLoggedInPresenter(matrixClient = matrixClient)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
createLoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
|
||||
|
||||
|
|
@ -518,51 +521,27 @@ class LoggedInPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CheckSlidingSyncProxyAvailability will not force the migration if native sliding sync is not supported too`() = runTest {
|
||||
val matrixClient = FakeMatrixClient(
|
||||
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
|
||||
availableSlidingSyncVersionsLambda = { Result.success(emptyList()) },
|
||||
)
|
||||
val presenter = createLoggedInPresenter(matrixClient = matrixClient)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
|
||||
|
||||
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - LogoutAndMigrateToNativeSlidingSync enables native sliding sync and logs out the user`() = runTest {
|
||||
val logoutLambda = lambdaRecorder<Boolean, Boolean, String?> { userInitiated, ignoreSdkError ->
|
||||
fun `present - LogoutAndMigrateToNativeSlidingSync logs out the user`() = runTest {
|
||||
val logoutLambda = lambdaRecorder<Boolean, Boolean, Unit> { userInitiated, ignoreSdkError ->
|
||||
assertThat(userInitiated).isTrue()
|
||||
assertThat(ignoreSdkError).isTrue()
|
||||
null
|
||||
}
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
val matrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
).apply {
|
||||
this.logoutLambda = logoutLambda
|
||||
}
|
||||
val appPreferencesStore = InMemoryAppPreferencesStore()
|
||||
val enableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(appPreferencesStore, this)
|
||||
val presenter = createLoggedInPresenter(matrixClient = matrixClient, enableNativeSlidingSyncUseCase = enableNativeSlidingSyncUseCase)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
createLoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
assertThat(appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
|
||||
|
||||
initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
|
||||
assertThat(logoutLambda.assertions().isCalledOnce())
|
||||
}
|
||||
}
|
||||
|
|
@ -572,15 +551,16 @@ class LoggedInPresenterTest {
|
|||
return awaitItem()
|
||||
}
|
||||
|
||||
private fun TestScope.createLoggedInPresenter(
|
||||
roomListService: RoomListService = FakeRoomListService(),
|
||||
private fun createLoggedInPresenter(
|
||||
syncState: SyncState = SyncState.Running,
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
pushService: PushService = FakePushService(),
|
||||
enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(InMemoryAppPreferencesStore(), this),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(roomListService = roomListService),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
): LoggedInPresenter {
|
||||
return LoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
|
|
@ -589,7 +569,7 @@ class LoggedInPresenterTest {
|
|||
sessionVerificationService = sessionVerificationService,
|
||||
analyticsService = analyticsService,
|
||||
encryptionService = encryptionService,
|
||||
enableNativeSlidingSyncUseCase = enableNativeSlidingSyncUseCase,
|
||||
buildMeta = buildMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,14 +166,17 @@ allprojects {
|
|||
// Register quality check tasks.
|
||||
tasks.register("runQualityChecks") {
|
||||
dependsOn(":tests:konsist:testDebugUnitTest")
|
||||
dependsOn(":app:lintGplayDebug")
|
||||
project.subprojects {
|
||||
// For some reason `findByName("lint")` doesn't work
|
||||
tasks.findByPath("$path:lint")?.let { dependsOn(it) }
|
||||
tasks.findByPath("$path:lintDebug")?.let { dependsOn(it) }
|
||||
tasks.findByName("detekt")?.let { dependsOn(it) }
|
||||
tasks.findByName("ktlintCheck")?.let { dependsOn(it) }
|
||||
// tasks.findByName("buildHealth")?.let { dependsOn(it) }
|
||||
}
|
||||
dependsOn(":app:knitCheck")
|
||||
|
||||
// Make sure all checks run even if some fail
|
||||
gradle.startParameter.isContinueOnFailure = true
|
||||
}
|
||||
|
||||
// Make sure to delete old screenshots before recording new ones
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit b4f0427e3595049d39846aabcdc06e818f2e96ea
|
||||
Subproject commit 6d96bf58aec2ecc77b408858272cd64ec26e10d0
|
||||
2
fastlane/metadata/android/en-US/changelogs/202503000.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202503000.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Main changes in this version: bug fixes.
|
||||
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_analytics_settings_help_us_improve">"Sorunları tanımlamamıza yardımcı olmak için anonim kullanım verilerini paylaşın."</string>
|
||||
<string name="screen_analytics_settings_read_terms">"Tüm şartlarımızı okuyabilirsiniz %1$s."</string>
|
||||
<string name="screen_analytics_settings_read_terms_content_link">"burada"</string>
|
||||
<string name="screen_analytics_settings_share_data">"Analitik verileri paylaşın"</string>
|
||||
</resources>
|
||||
|
|
@ -8,9 +8,9 @@
|
|||
package io.element.android.features.analytics.impl
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
|
|
@ -34,7 +34,7 @@ class AnalyticsOptInNode @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val activity = LocalContext.current as Activity
|
||||
val activity = requireNotNull(LocalActivity.current)
|
||||
val isDark = ElementTheme.isLightTheme.not()
|
||||
val state = presenter.present()
|
||||
AnalyticsOptInView(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
|
|
@ -111,7 +110,7 @@ private fun AnalyticsOptInHeader(
|
|||
.padding(8.dp),
|
||||
style = ElementTheme.typography.fontBodyMdRegular
|
||||
.copy(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_analytics_prompt_data_usage">"Hiçbir kişisel veriyi kaydetmeyeceğiz veya profillemeyeceğiz"</string>
|
||||
<string name="screen_analytics_prompt_help_us_improve">"Sorunları tanımlamamıza yardımcı olmak için anonim kullanım verilerini paylaşın."</string>
|
||||
<string name="screen_analytics_prompt_read_terms">"Tüm şartlarımızı okuyabilirsiniz %1$s."</string>
|
||||
<string name="screen_analytics_prompt_read_terms_content_link">"burada"</string>
|
||||
<string name="screen_analytics_prompt_settings">"Bu özelliği istediğiniz zaman kapatabilirsiniz"</string>
|
||||
<string name="screen_analytics_prompt_third_party_sharing">"Verilerinizi üçüncü taraflarla paylaşmayacağız"</string>
|
||||
<string name="screen_analytics_prompt_title">"%1$s geliştirilmesine yardımcı olun"</string>
|
||||
</resources>
|
||||
|
|
@ -30,6 +30,7 @@ interface ElementCallEntryPoint {
|
|||
* @param avatarUrl The avatar url of the room or DM.
|
||||
* @param timestamp The timestamp of the event that started the call.
|
||||
* @param notificationChannelId The id of the notification channel to use for the call notification.
|
||||
* @param textContent The text content of the notification. If null the default content from the system will be used.
|
||||
*/
|
||||
fun handleIncomingCall(
|
||||
callType: CallType.RoomCall,
|
||||
|
|
@ -40,5 +41,6 @@ interface ElementCallEntryPoint {
|
|||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
notificationChannelId: String,
|
||||
textContent: String?,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ setupAnvil()
|
|||
|
||||
dependencies {
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
|
|
@ -40,6 +41,7 @@ dependencies {
|
|||
implementation(projects.libraries.push.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.androidx.webkit)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
@ -59,6 +61,7 @@ dependencies {
|
|||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.appnavstate.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
|
|||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
notificationChannelId: String,
|
||||
textContent: String?,
|
||||
) {
|
||||
val incomingCallNotificationData = CallNotificationData(
|
||||
sessionId = callType.sessionId,
|
||||
|
|
@ -54,6 +55,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
|
|||
avatarUrl = avatarUrl,
|
||||
timestamp = timestamp,
|
||||
notificationChannelId = notificationChannelId,
|
||||
textContent = textContent,
|
||||
)
|
||||
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,4 +25,5 @@ data class CallNotificationData(
|
|||
val avatarUrl: String?,
|
||||
val notificationChannelId: String,
|
||||
val timestamp: Long,
|
||||
val textContent: String?,
|
||||
) : Parcelable
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import android.app.PendingIntent
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioManager
|
||||
import android.media.RingtoneManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.Person
|
||||
|
|
@ -63,6 +63,7 @@ class RingingCallNotificationCreator @Inject constructor(
|
|||
roomAvatarUrl: String?,
|
||||
notificationChannelId: String,
|
||||
timestamp: Long,
|
||||
textContent: String?,
|
||||
): Notification? {
|
||||
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
|
||||
val imageLoader = imageLoaderHolder.get(matrixClient)
|
||||
|
|
@ -84,7 +85,8 @@ class RingingCallNotificationCreator @Inject constructor(
|
|||
senderName = senderDisplayName,
|
||||
avatarUrl = roomAvatarUrl,
|
||||
notificationChannelId = notificationChannelId,
|
||||
timestamp = timestamp
|
||||
timestamp = timestamp,
|
||||
textContent = textContent,
|
||||
)
|
||||
|
||||
val declineIntent = PendingIntentCompat.getBroadcast(
|
||||
|
|
@ -107,8 +109,6 @@ class RingingCallNotificationCreator @Inject constructor(
|
|||
false
|
||||
)
|
||||
|
||||
// TODO use a fallback ringtone if the default ringtone is not available
|
||||
val ringtoneUri = runCatching { RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE) }.getOrNull()
|
||||
return NotificationCompat.Builder(context, notificationChannelId)
|
||||
.setSmallIcon(CommonDrawables.ic_notification_small)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
|
|
@ -120,10 +120,12 @@ class RingingCallNotificationCreator @Inject constructor(
|
|||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.apply {
|
||||
if (ringtoneUri != null) {
|
||||
setSound(ringtoneUri, AudioManager.STREAM_RING)
|
||||
if (textContent != null) {
|
||||
setContentText(textContent)
|
||||
// Else the content text is set by the style (will be "Incoming call")
|
||||
}
|
||||
}
|
||||
.setSound(Settings.System.DEFAULT_RINGTONE_URI, AudioManager.STREAM_RING)
|
||||
.setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
|
||||
.setContentIntent(answerIntent)
|
||||
.setDeleteIntent(declineIntent)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.sync.SyncState
|
|||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||
import io.element.android.services.analytics.api.ScreenTracker
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
|
@ -58,9 +59,9 @@ class CallScreenPresenter @AssistedInject constructor(
|
|||
private val dispatchers: CoroutineDispatchers,
|
||||
private val matrixClientsProvider: MatrixClientProvider,
|
||||
private val screenTracker: ScreenTracker,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val activeCallManager: ActiveCallManager,
|
||||
private val languageTagProvider: LanguageTagProvider,
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
) : Presenter<CallScreenState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -226,19 +227,13 @@ class CallScreenPresenter @AssistedInject constructor(
|
|||
if (state == SyncState.Running) {
|
||||
client.notifyCallStartIfNeeded(callType.roomId)
|
||||
} else {
|
||||
client.syncService().startSync()
|
||||
appForegroundStateService.updateIsInCallState(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
// We can't use the local coroutine scope here because it will be disposed before this effect
|
||||
appCoroutineScope.launch {
|
||||
client.syncService().run {
|
||||
if (syncState.value == SyncState.Running) {
|
||||
stopSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Make sure we mark the call as ended in the app state
|
||||
appForegroundStateService.updateIsInCallState(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureState
|
|||
import io.element.android.features.call.impl.pip.PipView
|
||||
import io.element.android.features.call.impl.services.CallForegroundService
|
||||
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
|
|
@ -61,6 +62,7 @@ class ElementCallActivity :
|
|||
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
||||
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||
@Inject lateinit var enterpriseService: EnterpriseService
|
||||
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
|
||||
|
||||
private lateinit var presenter: Presenter<CallScreenState>
|
||||
|
|
@ -109,7 +111,10 @@ class ElementCallActivity :
|
|||
setContent {
|
||||
val pipState = pictureInPicturePresenter.present()
|
||||
ListenToAndroidEvents(pipState)
|
||||
ElementThemeApp(appPreferencesStore) {
|
||||
ElementThemeApp(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
enterpriseService = enterpriseService,
|
||||
) {
|
||||
val state = presenter.present()
|
||||
eventSink = state.eventSink
|
||||
LaunchedEffect(state.isCallActive, state.isInWidgetMode) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import io.element.android.features.call.impl.di.CallBindings
|
|||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||
import io.element.android.features.call.impl.utils.CallState
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
|
|
@ -47,6 +48,9 @@ class IncomingCallActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var appPreferencesStore: AppPreferencesStore
|
||||
|
||||
@Inject
|
||||
lateinit var enterpriseService: EnterpriseService
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
|
@ -64,7 +68,10 @@ class IncomingCallActivity : AppCompatActivity() {
|
|||
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
||||
if (notificationData != null) {
|
||||
setContent {
|
||||
ElementThemeApp(appPreferencesStore) {
|
||||
ElementThemeApp(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
enterpriseService = enterpriseService,
|
||||
) {
|
||||
IncomingCallScreen(
|
||||
notificationData = notificationData,
|
||||
onAnswer = ::onAnswer,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ internal fun IncomingCallScreen(
|
|||
ActionButton(
|
||||
size = 64.dp,
|
||||
onClick = { onAnswer(notificationData) },
|
||||
icon = CompoundIcons.VoiceCall(),
|
||||
icon = CompoundIcons.VoiceCallSolid(),
|
||||
title = stringResource(CommonStrings.action_accept),
|
||||
backgroundColor = ElementTheme.colors.iconSuccessPrimary,
|
||||
borderColor = ElementTheme.colors.borderSuccessSubtle
|
||||
|
|
@ -173,6 +173,7 @@ internal fun IncomingCallScreenPreview() = ElementPreview {
|
|||
avatarUrl = null,
|
||||
notificationChannelId = "incoming_call",
|
||||
timestamp = 0L,
|
||||
textContent = null,
|
||||
),
|
||||
onAnswer = {},
|
||||
onCancel = {},
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ class DefaultActiveCallManager @Inject constructor(
|
|||
senderDisplayName = notificationData.senderName ?: notificationData.senderId.value,
|
||||
roomAvatarUrl = notificationData.avatarUrl,
|
||||
notificationChannelId = notificationData.notificationChannelId,
|
||||
timestamp = notificationData.timestamp
|
||||
timestamp = notificationData.timestamp,
|
||||
textContent = notificationData.textContent,
|
||||
) ?: return
|
||||
runCatching {
|
||||
notificationManagerCompat.notify(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="call_foreground_service_channel_title_android">"Devam eden çağrı"</string>
|
||||
<string name="call_foreground_service_message_android">"Aramaya geri dönmek için dokunun"</string>
|
||||
<string name="call_foreground_service_title_android">"☎️ Çağrı devam ediyor"</string>
|
||||
<string name="screen_incoming_call_subtitle_android">"Gelen Element Call"</string>
|
||||
</resources>
|
||||
|
|
@ -54,6 +54,7 @@ class DefaultElementCallEntryPointTest {
|
|||
avatarUrl = "avatarUrl",
|
||||
timestamp = 0,
|
||||
notificationChannelId = "notificationChannelId",
|
||||
textContent = "textContent",
|
||||
)
|
||||
|
||||
registerIncomingCallLambda.assertions().isCalledOnce()
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class RingingCallNotificationCreatorTest {
|
|||
roomAvatarUrl = "https://example.com/avatar.jpg",
|
||||
notificationChannelId = "channelId",
|
||||
timestamp = 0L,
|
||||
textContent = "textContent",
|
||||
)
|
||||
|
||||
private fun createRingingCallNotificationCreator(
|
||||
|
|
|
|||
|
|
@ -32,10 +32,9 @@ import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
|
|||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||
import io.element.android.services.analytics.api.ScreenTracker
|
||||
import io.element.android.services.analytics.test.FakeScreenTracker
|
||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
|
|
@ -243,7 +242,7 @@ class CallScreenPresenterTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
|
||||
fun `present - automatically sets the isInCall state when starting the call and disposing the screen`() = runTest {
|
||||
val navigator = FakeCallScreenNavigator()
|
||||
val widgetDriver = FakeMatrixWidgetDriver()
|
||||
val startSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
|
|
@ -251,6 +250,7 @@ class CallScreenPresenterTest {
|
|||
this.startSyncLambda = startSyncLambda
|
||||
}
|
||||
val matrixClient = FakeMatrixClient(syncService = syncService)
|
||||
val appForegroundStateService = FakeAppForegroundStateService()
|
||||
val presenter = createCallScreenPresenter(
|
||||
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||
widgetDriver = widgetDriver,
|
||||
|
|
@ -258,34 +258,7 @@ class CallScreenPresenterTest {
|
|||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }),
|
||||
screenTracker = FakeScreenTracker {},
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
consumeItemsUntilTimeout()
|
||||
|
||||
assert(startSyncLambda).isCalledOnce()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - automatically stops the Matrix client sync on dispose`() = runTest {
|
||||
val navigator = FakeCallScreenNavigator()
|
||||
val widgetDriver = FakeMatrixWidgetDriver()
|
||||
val stopSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(SyncState.Running).apply {
|
||||
this.stopSyncLambda = stopSyncLambda
|
||||
}
|
||||
val matrixClient = FakeMatrixClient(syncService = syncService)
|
||||
val presenter = createCallScreenPresenter(
|
||||
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||
widgetDriver = widgetDriver,
|
||||
navigator = navigator,
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }),
|
||||
screenTracker = FakeScreenTracker {},
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
val hasRun = Mutex(true)
|
||||
val job = launch {
|
||||
|
|
@ -296,11 +269,25 @@ class CallScreenPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
hasRun.lock()
|
||||
appForegroundStateService.isInCall.test {
|
||||
// The initial isInCall state will always be false
|
||||
assertThat(awaitItem()).isFalse()
|
||||
|
||||
job.cancelAndJoin()
|
||||
// Wait until the call starts
|
||||
hasRun.lock()
|
||||
|
||||
assert(stopSyncLambda).isCalledOnce()
|
||||
// Then it'll be true once the call is active
|
||||
assertThat(awaitItem()).isTrue()
|
||||
|
||||
// If we dispose the screen
|
||||
job.cancelAndJoin()
|
||||
|
||||
// The isInCall state is now false
|
||||
assertThat(awaitItem()).isFalse()
|
||||
|
||||
// And there are no more events
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -354,6 +341,7 @@ class CallScreenPresenterTest {
|
|||
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
||||
screenTracker: ScreenTracker = FakeScreenTracker(),
|
||||
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
|
||||
): CallScreenPresenter {
|
||||
val userAgentProvider = object : UserAgentProvider {
|
||||
override fun provide(): String {
|
||||
|
|
@ -369,10 +357,10 @@ class CallScreenPresenterTest {
|
|||
clock = clock,
|
||||
dispatchers = dispatchers,
|
||||
matrixClientsProvider = matrixClientsProvider,
|
||||
appCoroutineScope = this,
|
||||
activeCallManager = activeCallManager,
|
||||
screenTracker = screenTracker,
|
||||
languageTagProvider = FakeLanguageTagProvider("en-US"),
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,4 +22,5 @@ dependencies {
|
|||
implementation(projects.features.call.impl)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrix.test)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ fun aCallNotificationData(
|
|||
avatarUrl: String? = AN_AVATAR_URL,
|
||||
notificationChannelId: String = "channel_id",
|
||||
timestamp: Long = 0L,
|
||||
textContent: String? = null,
|
||||
): CallNotificationData = CallNotificationData(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
|
|
@ -40,4 +41,5 @@ fun aCallNotificationData(
|
|||
avatarUrl = avatarUrl,
|
||||
notificationChannelId = notificationChannelId,
|
||||
timestamp = timestamp,
|
||||
textContent = textContent,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,10 +11,20 @@ import io.element.android.features.call.api.CallType
|
|||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeElementCallEntryPoint(
|
||||
var startCallResult: (CallType) -> Unit = {},
|
||||
var handleIncomingCallResult: (CallType.RoomCall, EventId, UserId, String?, String?, String?, String) -> Unit = { _, _, _, _, _, _, _ -> }
|
||||
var startCallResult: (CallType) -> Unit = { lambdaError() },
|
||||
var handleIncomingCallResult: (
|
||||
CallType.RoomCall,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
String?,
|
||||
String?,
|
||||
String,
|
||||
String?,
|
||||
) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() }
|
||||
) : ElementCallEntryPoint {
|
||||
override fun startCall(callType: CallType) {
|
||||
startCallResult(callType)
|
||||
|
|
@ -28,8 +38,18 @@ class FakeElementCallEntryPoint(
|
|||
senderName: String?,
|
||||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
notificationChannelId: String
|
||||
notificationChannelId: String,
|
||||
textContent: String?,
|
||||
) {
|
||||
handleIncomingCallResult(callType, eventId, senderId, roomName, senderName, avatarUrl, notificationChannelId)
|
||||
handleIncomingCallResult(
|
||||
callType,
|
||||
eventId,
|
||||
senderId,
|
||||
roomName,
|
||||
senderName,
|
||||
avatarUrl,
|
||||
notificationChannelId,
|
||||
textContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.api
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
data class ConfirmingStartDmWithMatrixUser(
|
||||
val matrixUser: MatrixUser,
|
||||
) : AsyncAction.Confirming
|
||||
|
|
@ -11,7 +11,7 @@ import com.bumble.appyx.core.modality.BuildContext
|
|||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
|
||||
interface CreateRoomEntryPoint : FeatureEntryPoint {
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
|
@ -21,6 +21,6 @@ interface CreateRoomEntryPoint : FeatureEntryPoint {
|
|||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onSuccess(roomId: RoomId)
|
||||
fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,19 @@ package io.element.android.features.createroom.api
|
|||
import androidx.compose.runtime.MutableState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
interface StartDMAction {
|
||||
/**
|
||||
* Try to find an existing DM with the given user, or create one if none exists.
|
||||
* @param userId The user to start a DM with.
|
||||
* @param matrixUser The user to start a DM with.
|
||||
* @param createIfDmDoesNotExist If true, create a DM if one does not exist. If false and the DM
|
||||
* does not exist, the action will fail with the value [ConfirmingStartDmWithMatrixUser].
|
||||
* @param actionState The state to update with the result of the action.
|
||||
*/
|
||||
suspend fun execute(userId: UserId, actionState: MutableState<AsyncAction<RoomId>>)
|
||||
suspend fun execute(
|
||||
matrixUser: MatrixUser,
|
||||
createIfDmDoesNotExist: Boolean,
|
||||
actionState: MutableState<AsyncAction<RoomId>>,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom
|
||||
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import io.element.android.features.createroom.impl.CreateRoomFlowNode.NavTarget
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import io.element.android.libraries.architecture.overlay.operation.hide
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
|
||||
interface CreateRoomNavigator : Plugin {
|
||||
fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>)
|
||||
fun onCreateNewRoom()
|
||||
fun onShowJoinRoomByAddress()
|
||||
fun onDismissJoinRoomByAddress()
|
||||
}
|
||||
|
||||
class DefaultCreateRoomNavigator(
|
||||
private val backstack: BackStack<NavTarget>,
|
||||
private val overlay: Overlay<NavTarget>,
|
||||
private val openRoom: (RoomIdOrAlias, List<String>) -> Unit,
|
||||
) : CreateRoomNavigator {
|
||||
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) = openRoom(roomIdOrAlias, serverNames)
|
||||
|
||||
override fun onCreateNewRoom() {
|
||||
backstack.push(NavTarget.NewRoom)
|
||||
}
|
||||
|
||||
override fun onShowJoinRoomByAddress() {
|
||||
overlay.show(NavTarget.JoinByAddress)
|
||||
}
|
||||
|
||||
override fun onDismissJoinRoomByAddress() {
|
||||
overlay.hide()
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.createroom.CreateRoomNavigator
|
||||
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
|
||||
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
|
||||
import io.element.android.features.createroom.impl.di.CreateRoomComponent
|
||||
|
|
@ -46,6 +47,7 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
|
|||
private val component by lazy {
|
||||
parent!!.bindings<CreateRoomComponent.ParentBindings>().createRoomComponentBuilder().build()
|
||||
}
|
||||
private val navigator = plugins<CreateRoomNavigator>().first()
|
||||
|
||||
override val daggerComponent: Any
|
||||
get() = component
|
||||
|
|
@ -69,8 +71,7 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
|
|||
createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.ConfigureRoom -> {
|
||||
val callbacks = plugins<ConfigureRoomNode.Callback>()
|
||||
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = callbacks)
|
||||
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = listOf(navigator))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,25 +8,28 @@
|
|||
package io.element.android.features.createroom.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.createroom.DefaultCreateRoomNavigator
|
||||
import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
|
||||
import io.element.android.features.createroom.impl.joinbyaddress.JoinRoomByAddressNode
|
||||
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.OverlayView
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
|
|
@ -47,35 +50,38 @@ class CreateRoomFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object NewRoom : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object JoinByAddress : NavTarget
|
||||
}
|
||||
|
||||
private val navigator = DefaultCreateRoomNavigator(
|
||||
backstack = backstack,
|
||||
overlay = overlay,
|
||||
openRoom = { roomIdOrAlias, viaServers ->
|
||||
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomIdOrAlias, viaServers) }
|
||||
}
|
||||
)
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
val callback = object : CreateRoomRootNode.Callback {
|
||||
override fun onCreateNewRoom() {
|
||||
backstack.push(NavTarget.NewRoom)
|
||||
}
|
||||
|
||||
override fun onStartChatSuccess(roomId: RoomId) {
|
||||
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
|
||||
}
|
||||
}
|
||||
createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||
createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(navigator))
|
||||
}
|
||||
NavTarget.NewRoom -> {
|
||||
val callback = object : ConfigureRoomNode.Callback {
|
||||
override fun onCreateRoomSuccess(roomId: RoomId) {
|
||||
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) }
|
||||
}
|
||||
}
|
||||
createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(callback))
|
||||
createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(navigator))
|
||||
}
|
||||
NavTarget.JoinByAddress -> {
|
||||
createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
BackstackView()
|
||||
Box(modifier = modifier) {
|
||||
BackstackView()
|
||||
OverlayView(transitionHandler = remember { JumpToEndTransitionHandler() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,15 @@ package io.element.android.features.createroom.impl
|
|||
import androidx.compose.runtime.MutableState
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.createroom.api.StartDMAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.StartDMResult
|
||||
import io.element.android.libraries.matrix.api.room.startDM
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -26,9 +27,13 @@ class DefaultStartDMAction @Inject constructor(
|
|||
private val matrixClient: MatrixClient,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : StartDMAction {
|
||||
override suspend fun execute(userId: UserId, actionState: MutableState<AsyncAction<RoomId>>) {
|
||||
override suspend fun execute(
|
||||
matrixUser: MatrixUser,
|
||||
createIfDmDoesNotExist: Boolean,
|
||||
actionState: MutableState<AsyncAction<RoomId>>,
|
||||
) {
|
||||
actionState.value = AsyncAction.Loading
|
||||
when (val result = matrixClient.startDM(userId)) {
|
||||
when (val result = matrixClient.startDM(matrixUser.userId, createIfDmDoesNotExist)) {
|
||||
is StartDMResult.Success -> {
|
||||
if (result.isNew) {
|
||||
analyticsService.capture(CreatedRoom(isDM = true))
|
||||
|
|
@ -38,6 +43,9 @@ class DefaultStartDMAction @Inject constructor(
|
|||
is StartDMResult.Failure -> {
|
||||
actionState.value = AsyncAction.Failure(result.throwable)
|
||||
}
|
||||
StartDMResult.DmDoesNotExist -> {
|
||||
actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.createroom.CreateRoomNavigator
|
||||
import io.element.android.features.createroom.impl.di.CreateRoomScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
||||
@ContributesNode(CreateRoomScope::class)
|
||||
|
|
@ -29,6 +30,8 @@ class ConfigureRoomNode @AssistedInject constructor(
|
|||
private val presenter: ConfigureRoomPresenter,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private val navigator = plugins<CreateRoomNavigator>().first()
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onResume = {
|
||||
|
|
@ -37,14 +40,6 @@ class ConfigureRoomNode @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onCreateRoomSuccess(roomId: RoomId)
|
||||
}
|
||||
|
||||
private fun onCreateRoomSuccess(roomId: RoomId) {
|
||||
plugins<Callback>().forEach { it.onCreateRoomSuccess(roomId) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -52,7 +47,9 @@ class ConfigureRoomNode @AssistedInject constructor(
|
|||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = this::navigateUp,
|
||||
onCreateRoomSuccess = this::onCreateRoomSuccess,
|
||||
onCreateRoomSuccess = {
|
||||
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.joinbyaddress
|
||||
|
||||
sealed interface JoinRoomByAddressEvents {
|
||||
data object Dismiss : JoinRoomByAddressEvents
|
||||
data object Continue : JoinRoomByAddressEvents
|
||||
data class UpdateAddress(val address: String) : JoinRoomByAddressEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.joinbyaddress
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.createroom.CreateRoomNavigator
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class JoinRoomByAddressNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: JoinRoomByAddressPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private val navigator = plugins<CreateRoomNavigator>().first()
|
||||
private val presenter = presenterFactory.create(navigator)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
JoinRoomByAddressView(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.joinbyaddress
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.createroom.CreateRoomNavigator
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private const val ADDRESS_RESOLVE_TIMEOUT_IN_SECONDS = 10
|
||||
|
||||
class JoinRoomByAddressPresenter @AssistedInject constructor(
|
||||
@Assisted private val navigator: CreateRoomNavigator,
|
||||
private val client: MatrixClient,
|
||||
private val roomAliasHelper: RoomAliasHelper,
|
||||
) : Presenter<JoinRoomByAddressState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(navigator: CreateRoomNavigator): JoinRoomByAddressPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): JoinRoomByAddressState {
|
||||
var address by remember { mutableStateOf("") }
|
||||
var internalAddressState by remember { mutableStateOf<RoomAddressState>(RoomAddressState.Unknown) }
|
||||
var validateAddress: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
fun handleEvents(event: JoinRoomByAddressEvents) {
|
||||
when (event) {
|
||||
JoinRoomByAddressEvents.Continue -> {
|
||||
when (val currentState = internalAddressState) {
|
||||
is RoomAddressState.RoomFound -> onRoomFound(currentState)
|
||||
else -> validateAddress = true
|
||||
}
|
||||
}
|
||||
JoinRoomByAddressEvents.Dismiss -> navigator.onDismissJoinRoomByAddress()
|
||||
is JoinRoomByAddressEvents.UpdateAddress -> {
|
||||
validateAddress = false
|
||||
address = event.address.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RoomAddressStateEffect(
|
||||
fullAddress = address,
|
||||
onRoomAddressStateChange = { addressState ->
|
||||
internalAddressState = addressState
|
||||
if (addressState is RoomAddressState.RoomFound && validateAddress) {
|
||||
onRoomFound(addressState)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val addressState by remember {
|
||||
derivedStateOf {
|
||||
// We only want to show the "RoomFound" state as long as the user didn't validate the address.
|
||||
if (validateAddress || internalAddressState is RoomAddressState.RoomFound) {
|
||||
internalAddressState
|
||||
} else {
|
||||
RoomAddressState.Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JoinRoomByAddressState(
|
||||
address = address,
|
||||
addressState = addressState,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun onRoomFound(state: RoomAddressState.RoomFound) {
|
||||
navigator.onDismissJoinRoomByAddress()
|
||||
navigator.onOpenRoom(
|
||||
roomIdOrAlias = state.resolved.roomId.toRoomIdOrAlias(),
|
||||
serverNames = state.resolved.servers
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomAddressStateEffect(
|
||||
fullAddress: String,
|
||||
onRoomAddressStateChange: (RoomAddressState) -> Unit,
|
||||
) {
|
||||
val onChange by rememberUpdatedState(onRoomAddressStateChange)
|
||||
LaunchedEffect(fullAddress) {
|
||||
// Whenever the address changes, reset the state to unknown
|
||||
onChange(RoomAddressState.Unknown)
|
||||
// debounce the room address resolution
|
||||
delay(300)
|
||||
val roomAlias = tryOrNull { RoomAlias(fullAddress) }
|
||||
if (roomAlias != null && roomAliasHelper.isRoomAliasValid(roomAlias)) {
|
||||
onChange(RoomAddressState.Resolving)
|
||||
onChange(client.resolveRoomAddress(roomAlias))
|
||||
} else {
|
||||
onChange(RoomAddressState.Invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun MatrixClient.resolveRoomAddress(roomAlias: RoomAlias): RoomAddressState {
|
||||
return withTimeoutOrNull(ADDRESS_RESOLVE_TIMEOUT_IN_SECONDS.seconds) {
|
||||
resolveRoomAlias(roomAlias)
|
||||
.fold(
|
||||
onSuccess = { resolved ->
|
||||
if (resolved.isPresent) {
|
||||
RoomAddressState.RoomFound(resolved.get())
|
||||
} else {
|
||||
RoomAddressState.RoomNotFound
|
||||
}
|
||||
},
|
||||
onFailure = { _ -> RoomAddressState.RoomNotFound }
|
||||
)
|
||||
} ?: RoomAddressState.RoomNotFound
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.joinbyaddress
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||
|
||||
data class JoinRoomByAddressState(
|
||||
val address: String,
|
||||
val addressState: RoomAddressState,
|
||||
val eventSink: (JoinRoomByAddressEvents) -> Unit
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed interface RoomAddressState {
|
||||
data object Unknown : RoomAddressState
|
||||
data object Invalid : RoomAddressState
|
||||
data object Resolving : RoomAddressState
|
||||
data object RoomNotFound : RoomAddressState
|
||||
data class RoomFound(val resolved: ResolvedRoomAlias) : RoomAddressState
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.joinbyaddress
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||
|
||||
open class JoinRoomByAddressStateProvider : PreviewParameterProvider<JoinRoomByAddressState> {
|
||||
override val values: Sequence<JoinRoomByAddressState>
|
||||
get() = sequenceOf(
|
||||
aJoinRoomByAddressState(),
|
||||
aJoinRoomByAddressState(address = "#room-"),
|
||||
aJoinRoomByAddressState(address = "#room-", addressState = RoomAddressState.Invalid),
|
||||
aJoinRoomByAddressState(address = "#room-name:matrix.org", addressState = RoomAddressState.Resolving),
|
||||
aJoinRoomByAddressState(address = "#room-name-none:matrix.org", addressState = RoomAddressState.RoomNotFound),
|
||||
aJoinRoomByAddressState(
|
||||
address = "#room-name:matrix.org",
|
||||
addressState = RoomAddressState.RoomFound(ResolvedRoomAlias(RoomId("!aRoom:id"), emptyList())),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aJoinRoomByAddressState(
|
||||
address: String = "",
|
||||
addressState: RoomAddressState = RoomAddressState.Unknown,
|
||||
eventSink: (JoinRoomByAddressEvents) -> Unit = {},
|
||||
) = JoinRoomByAddressState(
|
||||
address = address,
|
||||
addressState = addressState,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.joinbyaddress
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun JoinRoomByAddressView(
|
||||
state: JoinRoomByAddressState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
modifier = modifier,
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = {
|
||||
state.eventSink(JoinRoomByAddressEvents.Dismiss)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
RoomAddressField(
|
||||
address = state.address,
|
||||
addressState = state.addressState,
|
||||
requestFocus = sheetState.isVisible,
|
||||
onAddressChange = {
|
||||
state.eventSink(JoinRoomByAddressEvents.UpdateAddress(it))
|
||||
},
|
||||
onContinue = {
|
||||
state.eventSink(JoinRoomByAddressEvents.Continue)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
showProgress = state.addressState is RoomAddressState.Resolving,
|
||||
onClick = {
|
||||
state.eventSink(JoinRoomByAddressEvents.Continue)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomAddressField(
|
||||
address: String,
|
||||
addressState: RoomAddressState,
|
||||
requestFocus: Boolean,
|
||||
onAddressChange: (String) -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
if (requestFocus) {
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
}
|
||||
TextField(
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
value = address,
|
||||
label = stringResource(R.string.screen_start_chat_join_room_by_address_action),
|
||||
placeholder = stringResource(R.string.screen_start_chat_join_room_by_address_placeholder),
|
||||
supportingText = when (addressState) {
|
||||
RoomAddressState.Invalid -> stringResource(R.string.screen_start_chat_join_room_by_address_invalid_address)
|
||||
is RoomAddressState.RoomFound -> stringResource(R.string.screen_start_chat_join_room_by_address_room_found)
|
||||
RoomAddressState.RoomNotFound -> stringResource(R.string.screen_start_chat_join_room_by_address_room_not_found)
|
||||
RoomAddressState.Unknown, RoomAddressState.Resolving -> stringResource(R.string.screen_start_chat_join_room_by_address_supporting_text)
|
||||
},
|
||||
validity = when (addressState) {
|
||||
RoomAddressState.Unknown, RoomAddressState.Resolving -> TextFieldValidity.None
|
||||
RoomAddressState.Invalid, RoomAddressState.RoomNotFound -> TextFieldValidity.Invalid
|
||||
is RoomAddressState.RoomFound -> TextFieldValidity.Valid
|
||||
},
|
||||
onValueChange = onAddressChange,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = { onContinue() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun JoinRoomByAddressViewPreview(
|
||||
@PreviewParameter(JoinRoomByAddressStateProvider::class) state: JoinRoomByAddressState
|
||||
) = ElementPreview {
|
||||
JoinRoomByAddressView(state = state)
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@
|
|||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
|
|
@ -20,9 +20,10 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.createroom.CreateRoomNavigator
|
||||
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
|
|
@ -33,18 +34,7 @@ class CreateRoomRootNode @AssistedInject constructor(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val inviteFriendsUseCase: InviteFriendsUseCase,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onCreateNewRoom()
|
||||
fun onStartChatSuccess(roomId: RoomId)
|
||||
}
|
||||
|
||||
private fun onCreateNewRoom() {
|
||||
plugins<Callback>().forEach { it.onCreateNewRoom() }
|
||||
}
|
||||
|
||||
private fun onStartChatSuccess(roomId: RoomId) {
|
||||
plugins<Callback>().forEach { it.onStartChatSuccess(roomId) }
|
||||
}
|
||||
private val navigator = plugins<CreateRoomNavigator>().first()
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
|
|
@ -55,13 +45,16 @@ class CreateRoomRootNode @AssistedInject constructor(
|
|||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val activity = LocalContext.current as Activity
|
||||
val activity = requireNotNull(LocalActivity.current)
|
||||
CreateRoomRootView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onCloseClick = this::navigateUp,
|
||||
onNewRoomClick = ::onCreateNewRoom,
|
||||
onOpenDM = ::onStartChatSuccess,
|
||||
onNewRoomClick = navigator::onCreateNewRoom,
|
||||
onOpenDM = {
|
||||
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
|
||||
},
|
||||
onJoinByAddressClick = navigator::onShowJoinRoomByAddress,
|
||||
onInviteFriendsClick = { invitePeople(activity) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ class CreateRoomRootPresenter @Inject constructor(
|
|||
fun handleEvents(event: CreateRoomRootEvents) {
|
||||
when (event) {
|
||||
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch {
|
||||
startDMAction.execute(event.matrixUser.userId, startDmActionState)
|
||||
startDMAction.execute(
|
||||
matrixUser = event.matrixUser,
|
||||
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
|
||||
actionState = startDmActionState,
|
||||
)
|
||||
}
|
||||
CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
|
||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||
|
|
@ -49,6 +50,9 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
|||
recentDirectRooms = aRecentDirectRoomList()
|
||||
)
|
||||
),
|
||||
aCreateRoomRootState(
|
||||
startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -28,6 +27,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.features.createroom.impl.components.UserListView
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
|
|
@ -43,6 +43,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
|||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -54,6 +55,7 @@ fun CreateRoomRootView(
|
|||
onNewRoomClick: () -> Unit,
|
||||
onOpenDM: (RoomId) -> Unit,
|
||||
onInviteFriendsClick: () -> Unit,
|
||||
onJoinByAddressClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
|
|
@ -88,6 +90,7 @@ fun CreateRoomRootView(
|
|||
state = state,
|
||||
onNewRoomClick = onNewRoomClick,
|
||||
onInvitePeopleClick = onInviteFriendsClick,
|
||||
onJoinByAddressClick = onJoinByAddressClick,
|
||||
onDmClick = onOpenDM,
|
||||
)
|
||||
}
|
||||
|
|
@ -110,6 +113,19 @@ fun CreateRoomRootView(
|
|||
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
},
|
||||
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
|
||||
confirmationDialog = { data ->
|
||||
if (data is ConfirmingStartDmWithMatrixUser) {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = data.matrixUser,
|
||||
onSendInvite = {
|
||||
state.eventSink(CreateRoomRootEvents.StartDM(data.matrixUser))
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +155,7 @@ private fun CreateRoomActionButtonsList(
|
|||
state: CreateRoomRootState,
|
||||
onNewRoomClick: () -> Unit,
|
||||
onInvitePeopleClick: () -> Unit,
|
||||
onJoinByAddressClick: () -> Unit,
|
||||
onDmClick: (RoomId) -> Unit,
|
||||
) {
|
||||
LazyColumn {
|
||||
|
|
@ -156,6 +173,13 @@ private fun CreateRoomActionButtonsList(
|
|||
onClick = onInvitePeopleClick,
|
||||
)
|
||||
}
|
||||
item {
|
||||
CreateRoomActionButton(
|
||||
iconRes = CompoundDrawables.ic_compound_room,
|
||||
text = stringResource(R.string.screen_start_chat_join_room_by_address_action),
|
||||
onClick = onJoinByAddressClick,
|
||||
)
|
||||
}
|
||||
if (state.userListState.recentDirectRooms.isNotEmpty()) {
|
||||
item {
|
||||
ListSectionHeader(
|
||||
|
|
@ -196,7 +220,7 @@ private fun CreateRoomActionButton(
|
|||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
resourceId = iconRes,
|
||||
contentDescription = null,
|
||||
)
|
||||
|
|
@ -216,6 +240,7 @@ internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProv
|
|||
onCloseClick = {},
|
||||
onNewRoomClick = {},
|
||||
onOpenDM = {},
|
||||
onJoinByAddressClick = {},
|
||||
onInviteFriendsClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<string name="screen_create_room_private_option_description">"Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць."</string>
|
||||
<string name="screen_create_room_private_option_title">"Прыватны пакой (толькі па запрашэнні)"</string>
|
||||
<string name="screen_create_room_public_option_description">"Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."</string>
|
||||
<string name="screen_create_room_public_option_title">"Публічны пакой (для ўсіх)"</string>
|
||||
<string name="screen_create_room_public_option_title">"Публічны пакой"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_title">"Хто заўгодна"</string>
|
||||
<string name="screen_create_room_room_access_section_header">"Доступ у пакой"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Папрасіце далучыцца"</string>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
<string name="screen_create_room_private_option_description">"Съобщенията в тази стая са шифровани. Шифроването не може да бъде изключено впоследствие."</string>
|
||||
<string name="screen_create_room_private_option_title">"Частна стая (само с покана)"</string>
|
||||
<string name="screen_create_room_public_option_description">"Съобщенията не са шифровани и всеки може да ги прочете. Можете да активирате шифроването на по-късна дата."</string>
|
||||
<string name="screen_create_room_public_option_title">"Публична стая (всеки)"</string>
|
||||
<string name="screen_create_room_room_name_label">"Име на стаята"</string>
|
||||
<string name="screen_create_room_title">"Създаване на стая"</string>
|
||||
<string name="screen_create_room_topic_label">"Тема за разговор (незадължително)"</string>
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
<string name="screen_create_room_private_option_title">"Privater Chatroom"</string>
|
||||
<string name="screen_create_room_public_option_description">"Jeder kann diesen Chatroom finden.
|
||||
Sie können dies aber jederzeit in den Chatroomeinstellungen ändern."</string>
|
||||
<string name="screen_create_room_public_option_title">"Öffentlicher Chatroom"</string>
|
||||
<string name="screen_create_room_public_option_title">"Öffentlicher Raum"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_description">"Jeder kann diesem Chatroom beitreten"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_title">"Jemand"</string>
|
||||
<string name="screen_create_room_room_access_section_header">"Chatroom Zugang"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_description">"Jeder kann darum bitten, dem Chatroom beizutreten, aber ein Administrator oder ein Moderator muss die Anfrage akzeptieren."</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Beitritt beantragen"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"Damit dieser Chatroom im öffentlichen Chatroomverzeichnis sichtbar ist, benötigen Sie eine Chatroomadresse."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"Chatroom Adresse"</string>
|
||||
<string name="screen_create_room_room_address_section_title">"Chatroomadresse"</string>
|
||||
<string name="screen_create_room_room_name_label">"Raumname"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chatrooms"</string>
|
||||
<string name="screen_create_room_title">"Raum erstellen"</string>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
<string name="screen_create_room_room_access_section_knocking_option_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στο δωμάτιο, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχθεί το αίτημα"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Αίτημα συμμετοχής"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"Για να είναι ορατό αυτό το δωμάτιο στον κατάλογο των δημόσιων δωματίων, θα χρειαστείς μια διεύθυνση δωματίου."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"Διεύθυνση δωματίου"</string>
|
||||
<string name="screen_create_room_room_name_label">"Όνομα δωματίου"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">"Ορατότητα δωματίου"</string>
|
||||
<string name="screen_create_room_title">"Δημιούργησε ένα δωμάτιο"</string>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
<string name="screen_create_room_private_option_description">"Los mensajes de esta sala están cifrados. La encriptación no se puede desactivar después."</string>
|
||||
<string name="screen_create_room_private_option_title">"Sala privada (sólo con invitación)"</string>
|
||||
<string name="screen_create_room_public_option_description">"Los mensajes no están cifrados y cualquiera puede leerlos. Puedes activar la encriptación más adelante."</string>
|
||||
<string name="screen_create_room_public_option_title">"Sala pública (cualquiera)"</string>
|
||||
<string name="screen_create_room_room_name_label">"Nombre de la sala"</string>
|
||||
<string name="screen_create_room_title">"Crear una sala"</string>
|
||||
<string name="screen_create_room_topic_label">"Tema (opcional)"</string>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<string name="screen_create_room_private_option_description">"پیامهای این اتاق رمز شدهاند. رمزنگاری نمیتواند از این پس تغییر کند."</string>
|
||||
<string name="screen_create_room_private_option_title">"اتاق خصوصی (فقط دعوت)"</string>
|
||||
<string name="screen_create_room_public_option_description">"پیامها رمزنگاری نشده و هرکسی میتواند بخواندشان. میتوانید بعداً رمزنگاری را به کار بیندازید."</string>
|
||||
<string name="screen_create_room_public_option_title">"اتاق عمومی (هرکسی)"</string>
|
||||
<string name="screen_create_room_public_option_title">"اتاق عمومی"</string>
|
||||
<string name="screen_create_room_room_name_label">"نام اتاق"</string>
|
||||
<string name="screen_create_room_title">"ایجاد اتاق"</string>
|
||||
<string name="screen_create_room_topic_label">"موضوع (اختیاری)"</string>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Ezt bármikor módosíthatja a szobabeállításokban."</string>
|
|||
<string name="screen_create_room_room_access_section_knocking_option_description">"Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Csatlakozás kérése"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"Szoba címe"</string>
|
||||
<string name="screen_create_room_room_address_section_title">"A szoba címe"</string>
|
||||
<string name="screen_create_room_room_name_label">"Szoba neve"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">"Szoba láthatósága"</string>
|
||||
<string name="screen_create_room_title">"Szoba létrehozása"</string>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."</string>
|
|||
<string name="screen_create_room_room_access_section_knocking_option_description">"Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Minta untuk bergabung"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"Alamat ruangan"</string>
|
||||
<string name="screen_create_room_room_name_label">"Nama ruangan"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">"Keterlihatan ruangan"</string>
|
||||
<string name="screen_create_room_title">"Buat ruangan"</string>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
<string name="screen_create_room_private_option_title">"კერძო ოთახი"</string>
|
||||
<string name="screen_create_room_public_option_description">"ყველას ამ ოთახის მოძებნა შეუძლია.
|
||||
თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."</string>
|
||||
<string name="screen_create_room_public_option_title">"საჯარო ოთახი"</string>
|
||||
<string name="screen_create_room_room_name_label">"ოთახის სახელი"</string>
|
||||
<string name="screen_create_room_title">"ოთახის შექმნა"</string>
|
||||
<string name="screen_create_room_topic_label">"თემა (სურვილისამებრ)"</string>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
<string name="screen_create_room_private_option_title">"Sala privativa (somente por convite)"</string>
|
||||
<string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala.
|
||||
Você pode mudar isso a qualquer momento nas configurações da sala."</string>
|
||||
<string name="screen_create_room_public_option_title">"Sala pública (qualquer pessoa)"</string>
|
||||
<string name="screen_create_room_room_name_label">"Nome da sala"</string>
|
||||
<string name="screen_create_room_title">"Criar uma sala"</string>
|
||||
<string name="screen_create_room_topic_label">"Tópico (opcional)"</string>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<string name="screen_create_room_action_create_room">"Nova sala"</string>
|
||||
<string name="screen_create_room_add_people_title">"Convidar pessoas"</string>
|
||||
<string name="screen_create_room_error_creating_room">"Ocorreu um erro ao criar a sala"</string>
|
||||
<string name="screen_create_room_private_option_description">"Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são encriptadas ponta a ponta."</string>
|
||||
<string name="screen_create_room_private_option_description">"Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são cifradas ponta-a-ponta."</string>
|
||||
<string name="screen_create_room_private_option_title">"Sala privada"</string>
|
||||
<string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala.
|
||||
Pode alterar esta opção nas definições da sala."</string>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ Puteți modifica acest lucru oricând în setări."</string>
|
|||
<string name="screen_create_room_room_access_section_knocking_option_description">"Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte solicitarea"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Cereți să vă alăturați"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"Adresa camerei"</string>
|
||||
<string name="screen_create_room_room_name_label">"Numele camerei"</string>
|
||||
<string name="screen_create_room_title">"Creați o cameră"</string>
|
||||
<string name="screen_create_room_topic_label">"Subiect (opțional)"</string>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ Du kan ändra detta när som helst i rumsinställningarna."</string>
|
|||
<string name="screen_create_room_room_access_section_header">"Rumsåtkomst"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_description">"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Be om att gå med"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."</string>
|
||||
<string name="screen_create_room_room_name_label">"Rumsnamn"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">"Rumssynlighet"</string>
|
||||
<string name="screen_create_room_title">"Skapa ett rum"</string>
|
||||
<string name="screen_create_room_topic_label">"Ämne (valfritt)"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"Ett fel uppstod när du försökte starta en chatt"</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_create_room_action_create_room">"Yeni oda"</string>
|
||||
<string name="screen_create_room_add_people_title">"İnsanları davet et"</string>
|
||||
<string name="screen_create_room_error_creating_room">"Oda oluşturulurken bir hata oluştu"</string>
|
||||
<string name="screen_create_room_private_option_description">"Bu odaya yalnızca davet edilen kişiler erişebilir. Tüm mesajlar uçtan uca şifrelenir."</string>
|
||||
<string name="screen_create_room_private_option_title">"Özel oda"</string>
|
||||
<string name="screen_create_room_public_option_description">"Bu odayı herkes bulabilir.
|
||||
Bunu istediğiniz zaman oda ayarlarından değiştirebilirsiniz."</string>
|
||||
<string name="screen_create_room_public_option_title">"Herkese açık oda"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_description">"Bu odaya herkes katılabilir"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_title">"Herkes"</string>
|
||||
<string name="screen_create_room_room_access_section_header">"Oda Erişimi"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_description">"Herkes odaya katılmayı isteyebilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekecektir"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Katılmak için sor"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"Bu odanın genel oda dizininde görünür olması için bir oda adresine ihtiyacınız olacaktır."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"Oda adresi"</string>
|
||||
<string name="screen_create_room_room_name_label">"Oda adı"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">"Oda görünürlüğü"</string>
|
||||
<string name="screen_create_room_title">"Bir oda oluştur"</string>
|
||||
<string name="screen_create_room_topic_label">"Konu (isteğe bağlı)"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"Sohbet başlatmaya çalışırken bir hata oluştu"</string>
|
||||
</resources>
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
<string name="screen_create_room_private_option_title">"Приватна кімната (тільки за запрошенням)"</string>
|
||||
<string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату.
|
||||
Ви можете змінити це в будь-який час у налаштуваннях кімнати."</string>
|
||||
<string name="screen_create_room_public_option_title">"Публічна кімната"</string>
|
||||
<string name="screen_create_room_public_option_title">"Загальнодоступна кімната"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_description">"Будь-хто може приєднатися до цієї кімнати"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_title">"Кожний"</string>
|
||||
<string name="screen_create_room_room_access_section_header">"Доступ до кімнати"</string>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
<string name="screen_create_room_private_option_description">"Bu xonadagi xabarlar shifrlangan. Keyinchalik shifrlashni o‘chirib bo‘lmaydi."</string>
|
||||
<string name="screen_create_room_private_option_title">"Shaxsiy xona (faqat taklif)"</string>
|
||||
<string name="screen_create_room_public_option_description">"Xabarlar shifrlanmagan va har kim ularni o\'qiy oladi. Keyinchalik shifrlashni yoqishingiz mumkin."</string>
|
||||
<string name="screen_create_room_public_option_title">"Jamoat xonasi (har kim)"</string>
|
||||
<string name="screen_create_room_room_name_label">"Xona nomi"</string>
|
||||
<string name="screen_create_room_title">"Xonani yaratish"</string>
|
||||
<string name="screen_create_room_topic_label">"Mavzu (ixtiyoriy)"</string>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<string name="screen_create_room_private_option_title">"私密聊天室"</string>
|
||||
<string name="screen_create_room_public_option_description">"任何人都可以找到此聊天室。
|
||||
您隨時都可以在聊天室設定中變更此設定。"</string>
|
||||
<string name="screen_create_room_public_option_title">"公開聊天室"</string>
|
||||
<string name="screen_create_room_public_option_title">"公開的聊天室"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_description">"任何人都可以加入此聊天室"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_title">"任何人"</string>
|
||||
<string name="screen_create_room_room_access_section_header">"聊天室存取權"</string>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<string name="screen_create_room_private_option_title">"私有聊天室"</string>
|
||||
<string name="screen_create_room_public_option_description">"任何人都能找到此聊天室。
|
||||
你可以随时在聊天室设置中更改。"</string>
|
||||
<string name="screen_create_room_public_option_title">"公开聊天室"</string>
|
||||
<string name="screen_create_room_public_option_title">"公共聊天室"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_description">"任何人都可以加入此房间"</string>
|
||||
<string name="screen_create_room_room_access_section_anyone_option_title">"任何人"</string>
|
||||
<string name="screen_create_room_room_access_section_header">"房间访问权限"</string>
|
||||
|
|
|
|||
|
|
@ -20,4 +20,10 @@ You can change this anytime in room settings."</string>
|
|||
<string name="screen_create_room_title">"Create a room"</string>
|
||||
<string name="screen_create_room_topic_label">"Topic (optional)"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_action">"Join room by address"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_invalid_address">"Not a valid address"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_placeholder">"Enter…"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_room_found">"Matching room found"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_room_not_found">"Room not found"</string>
|
||||
<string name="screen_start_chat_join_room_by_address_supporting_text">"e.g. #room-name:matrix.org"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ package io.element.android.features.createroom.impl
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -28,10 +29,12 @@ class DefaultStartDMActionTest {
|
|||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenFindDmResult(A_ROOM_ID)
|
||||
}
|
||||
val action = createStartDMAction(matrixClient)
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val action = createStartDMAction(matrixClient, analyticsService)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
action.execute(A_USER_ID, state)
|
||||
action.execute(aMatrixUser(), true, state)
|
||||
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
|
||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -43,21 +46,38 @@ class DefaultStartDMActionTest {
|
|||
val analyticsService = FakeAnalyticsService()
|
||||
val action = createStartDMAction(matrixClient, analyticsService)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
action.execute(A_USER_ID, state)
|
||||
action.execute(aMatrixUser(), true, state)
|
||||
assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
|
||||
assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest {
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenFindDmResult(null)
|
||||
givenCreateDmResult(Result.success(A_ROOM_ID))
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val action = createStartDMAction(matrixClient, analyticsService)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
val matrixUser = aMatrixUser()
|
||||
action.execute(matrixUser, false, state)
|
||||
assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser))
|
||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when dm creation fails, assert state is updated with given error`() = runTest {
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenFindDmResult(null)
|
||||
givenCreateDmResult(Result.failure(A_THROWABLE))
|
||||
}
|
||||
val action = createStartDMAction(matrixClient)
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val action = createStartDMAction(matrixClient, analyticsService)
|
||||
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized)
|
||||
action.execute(A_USER_ID, state)
|
||||
action.execute(aMatrixUser(), true, state)
|
||||
assertThat(state.value).isEqualTo(AsyncAction.Failure(A_THROWABLE))
|
||||
assertThat(analyticsService.capturedEvents).isEmpty()
|
||||
}
|
||||
|
||||
private fun createStartDMAction(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl
|
||||
|
||||
import io.element.android.features.createroom.CreateRoomNavigator
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
|
||||
class FakeCreateRoomNavigator(
|
||||
private val openRoomLambda: (roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) -> Unit = { _, _ -> },
|
||||
private val createNewRoomLambda: () -> Unit = {},
|
||||
private val showJoinRoomByAddressLambda: () -> Unit = {},
|
||||
private val dismissJoinRoomByAddressLambda: () -> Unit = {},
|
||||
) : CreateRoomNavigator {
|
||||
override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
|
||||
openRoomLambda(roomIdOrAlias, serverNames)
|
||||
}
|
||||
|
||||
override fun onCreateNewRoom() {
|
||||
createNewRoomLambda()
|
||||
}
|
||||
|
||||
override fun onShowJoinRoomByAddress() {
|
||||
showJoinRoomByAddressLambda()
|
||||
}
|
||||
|
||||
override fun onDismissJoinRoomByAddress() {
|
||||
dismissJoinRoomByAddressLambda()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.joinbyaddress
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.createroom.CreateRoomNavigator
|
||||
import io.element.android.features.createroom.impl.FakeCreateRoomNavigator
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class JoinRoomByAddressPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createJoinRoomByAddressPresenter()
|
||||
presenter.test {
|
||||
with(awaitItem()) {
|
||||
assertThat(address).isEmpty()
|
||||
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - invalid address`() = runTest {
|
||||
val presenter = createJoinRoomByAddressPresenter(
|
||||
roomAliasHelper = FakeRoomAliasHelper(
|
||||
isRoomAliasValidLambda = { false }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
with(awaitItem()) {
|
||||
eventSink(JoinRoomByAddressEvents.UpdateAddress("invalid_address"))
|
||||
}
|
||||
with(awaitItem()) {
|
||||
assertThat(address).isEqualTo("invalid_address")
|
||||
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
|
||||
eventSink(JoinRoomByAddressEvents.Continue)
|
||||
}
|
||||
// The address should be marked as invalid only after the user tries to continue
|
||||
with(awaitItem()) {
|
||||
assertThat(address).isEqualTo("invalid_address")
|
||||
assertThat(addressState).isEqualTo(RoomAddressState.Invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room found`() = runTest {
|
||||
val openRoomLambda = lambdaRecorder<RoomIdOrAlias, List<String>, Unit> { _, _ -> }
|
||||
val dismissJoinRoomByAddressLambda = lambdaRecorder<Unit> { }
|
||||
val navigator = FakeCreateRoomNavigator(
|
||||
openRoomLambda = openRoomLambda,
|
||||
dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
|
||||
)
|
||||
val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
|
||||
presenter.test {
|
||||
with(awaitItem()) {
|
||||
eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_found:matrix.org"))
|
||||
}
|
||||
with(awaitItem()) {
|
||||
assertThat(address).isEqualTo("#room_found:matrix.org")
|
||||
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
|
||||
}
|
||||
with(awaitItem()) {
|
||||
assertThat(address).isEqualTo("#room_found:matrix.org")
|
||||
assertThat(addressState).isInstanceOf(RoomAddressState.RoomFound::class.java)
|
||||
eventSink(JoinRoomByAddressEvents.Continue)
|
||||
}
|
||||
assert(openRoomLambda).isCalledOnce()
|
||||
assert(dismissJoinRoomByAddressLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room not found`() = runTest {
|
||||
val presenter = createJoinRoomByAddressPresenter(
|
||||
matrixClient = FakeMatrixClient(
|
||||
resolveRoomAliasResult = { Result.failure(RuntimeException()) }
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
with(awaitItem()) {
|
||||
eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_not_found:matrix.org"))
|
||||
}
|
||||
with(awaitItem()) {
|
||||
assertThat(address).isEqualTo("#room_not_found:matrix.org")
|
||||
assertThat(addressState).isEqualTo(RoomAddressState.Unknown)
|
||||
eventSink(JoinRoomByAddressEvents.Continue)
|
||||
}
|
||||
with(awaitItem()) {
|
||||
assertThat(address).isEqualTo("#room_not_found:matrix.org")
|
||||
assertThat(addressState).isEqualTo(RoomAddressState.Resolving)
|
||||
}
|
||||
with(awaitItem()) {
|
||||
assertThat(address).isEqualTo("#room_not_found:matrix.org")
|
||||
assertThat(addressState).isEqualTo(RoomAddressState.RoomNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - dismiss`() = runTest {
|
||||
val dismissJoinRoomByAddressLambda = lambdaRecorder<Unit> { }
|
||||
val navigator = FakeCreateRoomNavigator(
|
||||
dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda
|
||||
)
|
||||
val presenter = createJoinRoomByAddressPresenter(navigator = navigator)
|
||||
presenter.test {
|
||||
with(awaitItem()) {
|
||||
eventSink(JoinRoomByAddressEvents.Dismiss)
|
||||
}
|
||||
assert(dismissJoinRoomByAddressLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createJoinRoomByAddressPresenter(
|
||||
navigator: CreateRoomNavigator = FakeCreateRoomNavigator(),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
|
||||
): JoinRoomByAddressPresenter {
|
||||
return JoinRoomByAddressPresenter(
|
||||
navigator = navigator,
|
||||
client = matrixClient,
|
||||
roomAliasHelper = roomAliasHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl.joinbyaddress
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class JoinRoomByAddressViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `entering text emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<JoinRoomByAddressEvents>()
|
||||
rule.setJoinRoomByAddressView(
|
||||
aJoinRoomByAddressState(
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
val text = rule.activity.getString(R.string.screen_start_chat_join_room_by_address_action)
|
||||
rule.onNodeWithText(text).performTextInput("#address:matrix.org")
|
||||
eventsRecorder.assertSingle(JoinRoomByAddressEvents.UpdateAddress("#address:matrix.org"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on continue emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<JoinRoomByAddressEvents>()
|
||||
rule.setJoinRoomByAddressView(
|
||||
aJoinRoomByAddressState(
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_continue)
|
||||
eventsRecorder.assertSingle(JoinRoomByAddressEvents.Continue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView(
|
||||
state: JoinRoomByAddressState,
|
||||
) {
|
||||
setContent {
|
||||
JoinRoomByAddressView(state = state)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,16 +7,19 @@
|
|||
|
||||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.createroom.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.features.createroom.api.StartDMAction
|
||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
|
||||
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
|
||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||
import io.element.android.features.createroom.test.FakeStartDMAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
|
|
@ -24,6 +27,9 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
|
|||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
|
@ -33,46 +39,130 @@ class CreateRoomRootPresenterTest {
|
|||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - start DM action complete scenario`() = runTest {
|
||||
val startDMAction = FakeStartDMAction()
|
||||
fun `present - start DM action failure scenario`() = runTest {
|
||||
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMFailureResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
|
||||
assertThat(initialState.userListState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
|
||||
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
|
||||
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
|
||||
|
||||
// Failure
|
||||
startDMAction.givenExecuteResult(startDMFailureResult)
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
}
|
||||
|
||||
// Success
|
||||
startDMAction.givenExecuteResult(startDMSuccessResult)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
assertThat(state.startDmAction.isUninitialized()).isTrue()
|
||||
}
|
||||
assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action success scenario`() = runTest {
|
||||
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMSuccessResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
|
||||
assertThat(initialState.userListState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.startDmAction).isEqualTo(startDMSuccessResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action confirmation scenario - cancel`() = runTest {
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
// Cancelling should not create the DM
|
||||
confirmingState.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.startDmAction.isUninitialized()).isTrue()
|
||||
executeResult.assertions().isCalledExactly(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - start DM action confirmation scenario - confirm`() = runTest {
|
||||
val matrixUser = MatrixUser(UserId("@name:domain"))
|
||||
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
|
||||
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
|
||||
actionState.value = startDMConfirmationResult
|
||||
}
|
||||
val startDMAction = FakeStartDMAction(executeResult = executeResult)
|
||||
val presenter = createCreateRoomRootPresenter(startDMAction)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult)
|
||||
executeResult.assertions().isCalledOnce().with(
|
||||
value(matrixUser),
|
||||
value(false),
|
||||
any(),
|
||||
)
|
||||
// Start DM again should invoke the action with createIfDmDoesNotExist = true
|
||||
confirmingState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
|
||||
executeResult.assertions().isCalledExactly(2).withSequence(
|
||||
listOf(value(matrixUser), value(false), any()),
|
||||
listOf(value(matrixUser), value(true), any()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCreateRoomRootPresenter(
|
||||
startDMAction: StartDMAction = FakeStartDMAction(),
|
||||
): CreateRoomRootPresenter {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,21 @@ class CreateRoomRootViewTest {
|
|||
rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on Join room by address invokes the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setCreateRoomRootView(
|
||||
aCreateRoomRootState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onJoinRoomByAddressClick = it
|
||||
)
|
||||
rule.clickOn(R.string.screen_start_chat_join_room_by_address_action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreateRoomRootView(
|
||||
|
|
@ -109,6 +124,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreat
|
|||
onNewRoomClick: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onInviteFriendsClick: () -> Unit = EnsureNeverCalled(),
|
||||
onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
CreateRoomRootView(
|
||||
|
|
@ -117,6 +133,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreat
|
|||
onNewRoomClick = onNewRoomClick,
|
||||
onOpenDM = onOpenDM,
|
||||
onInviteFriendsClick = onInviteFriendsClick,
|
||||
onJoinByAddressClick = onJoinRoomByAddressClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue