Merge branch 'release/25.03.0' into main

This commit is contained in:
Benoit Marty 2025-02-27 15:04:32 +01:00
commit e4cf14a0e4
1898 changed files with 11788 additions and 6033 deletions

View file

@ -20,7 +20,7 @@ jobs:
- run: | - run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger - name: Danger
uses: danger/danger-js@12.3.3 uses: danger/danger-js@12.3.4
with: with:
args: "--dangerfile ./tools/danger/dangerfile.js" args: "--dangerfile ./tools/danger/dangerfile.js"
env: env:

View file

@ -138,7 +138,7 @@ jobs:
- name: Build Fdroid Debug - name: Build Fdroid Debug
run: ./gradlew :app:compileFdroidDebugKotlin $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:compileFdroidDebugKotlin $CI_GRADLE_ARG_PROPERTIES
- name: Run lint - 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 - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -294,7 +294,7 @@ jobs:
yarn add danger-plugin-lint-report --dev yarn add danger-plugin-lint-report --dev
- name: Danger lint - name: Danger lint
if: always() if: always()
uses: danger/danger-js@12.3.3 uses: danger/danger-js@12.3.4
with: with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js" args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env: env:

View file

@ -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) Changes in Element X v0.7.6 (2024-12-20)
======================================== ========================================

View file

@ -10,6 +10,7 @@
import com.android.build.api.variant.FilterConfiguration.FilterType.ABI import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
import com.android.build.gradle.internal.tasks.factory.dependsOn import com.android.build.gradle.internal.tasks.factory.dependsOn
import com.android.build.gradle.tasks.GenerateBuildConfig import com.android.build.gradle.tasks.GenerateBuildConfig
import config.BuildTimeConfig
import extension.AssetCopyTask import extension.AssetCopyTask
import extension.ComponentMergingStrategy import extension.ComponentMergingStrategy
import extension.GitBranchNameValueSource import extension.GitBranchNameValueSource
@ -43,11 +44,7 @@ android {
namespace = "io.element.android.x" namespace = "io.element.android.x"
defaultConfig { defaultConfig {
applicationId = if (isEnterpriseBuild) { applicationId = BuildTimeConfig.APPLICATION_ID
"io.element.enterprise"
} else {
"io.element.android.x"
}
targetSdk = Versions.TARGET_SDK targetSdk = Versions.TARGET_SDK
versionCode = Versions.VERSION_CODE versionCode = Versions.VERSION_CODE
versionName = Versions.VERSION_NAME versionName = Versions.VERSION_NAME
@ -97,11 +94,7 @@ android {
} }
} }
val baseAppName = if (isEnterpriseBuild) { val baseAppName = BuildTimeConfig.APPLICATION_NAME
"Element Enterprise"
} else {
"Element X"
}
logger.warnInBox("Building $baseAppName") logger.warnInBox("Building $baseAppName")
buildTypes { buildTypes {

View file

@ -14,7 +14,6 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier 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.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver 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.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
@ -61,7 +61,10 @@ class MainActivity : NodeActivity() {
@Composable @Composable
private fun MainContent(appBindings: AppBindings) { private fun MainContent(appBindings: AppBindings) {
val migrationState = appBindings.migrationEntryPoint().present() val migrationState = appBindings.migrationEntryPoint().present()
ElementThemeApp(appBindings.preferencesStore()) { ElementThemeApp(
appPreferencesStore = appBindings.preferencesStore(),
enterpriseService = appBindings.enterpriseService(),
) {
CompositionLocalProvider( CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(), LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
LocalUriHandler provides SafeUriHandler(this), LocalUriHandler provides SafeUriHandler(this),
@ -69,8 +72,8 @@ class MainActivity : NodeActivity() {
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(ElementTheme.colors.bgCanvasDefault),
) { ) {
if (migrationState.migrationAction.isSuccess()) { if (migrationState.migrationAction.isSuccess()) {
MainNodeHost() MainNodeHost()

View file

@ -9,6 +9,7 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.api.MigrationEntryPoint 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.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
@ -35,4 +36,6 @@ interface AppBindings {
fun lockScreenEntryPoint(): LockScreenEntryPoint fun lockScreenEntryPoint(): LockScreenEntryPoint
fun analyticsService(): AnalyticsService fun analyticsService(): AnalyticsService
fun enterpriseService(): EnterpriseService
} }

View file

@ -73,23 +73,26 @@ object AppModule {
@ApplicationContext context: Context, @ApplicationContext context: Context,
buildType: BuildType, buildType: BuildType,
enterpriseService: EnterpriseService, enterpriseService: EnterpriseService,
) = BuildMeta( ): BuildMeta {
isDebuggable = BuildConfig.DEBUG, val applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name)
buildType = buildType, return BuildMeta(
applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name), isDebuggable = BuildConfig.DEBUG,
productionApplicationName = ApplicationConfig.PRODUCTION_APPLICATION_NAME, buildType = buildType,
desktopApplicationName = ApplicationConfig.DESKTOP_APPLICATION_NAME, applicationName = applicationName,
applicationId = BuildConfig.APPLICATION_ID, productionApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild, desktopApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME,
// TODO EAx Config.LOW_PRIVACY_LOG_ENABLE, applicationId = BuildConfig.APPLICATION_ID,
lowPrivacyLoggingEnabled = false, isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
versionName = BuildConfig.VERSION_NAME, // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
versionCode = context.getVersionCodeFromManifest(), lowPrivacyLoggingEnabled = false,
gitRevision = BuildConfig.GIT_REVISION, versionName = BuildConfig.VERSION_NAME,
gitBranchName = BuildConfig.GIT_BRANCH_NAME, versionCode = context.getVersionCodeFromManifest(),
flavorDescription = BuildConfig.FLAVOR_DESCRIPTION, gitRevision = BuildConfig.GIT_REVISION,
flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION, gitBranchName = BuildConfig.GIT_BRANCH_NAME,
) flavorDescription = BuildConfig.FLAVOR_DESCRIPTION,
flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION,
)
}
@Provides @Provides
@SingleIn(AppScope::class) @SingleIn(AppScope::class)

View file

@ -25,6 +25,7 @@
<locale android:name="ru"/> <locale android:name="ru"/>
<locale android:name="sk"/> <locale android:name="sk"/>
<locale android:name="sv"/> <locale android:name="sv"/>
<locale android:name="tr"/>
<locale android:name="uk"/> <locale android:name="uk"/>
<locale android:name="uz"/> <locale android:name="uz"/>
<locale android:name="zh-CN"/> <locale android:name="zh-CN"/>

View file

@ -1,8 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?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 --> <!-- Ref: https://developer.android.com/training/articles/security-config.html -->
<!-- By default, do not allow clearText traffic --> <!-- 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 --> <!-- Allow clearText traffic on some specified host -->
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
@ -24,12 +31,4 @@
<domain includeSubdomains="true">lan</domain> <domain includeSubdomains="true">lan</domain>
<domain includeSubdomains="true">localdomain</domain> <domain includeSubdomains="true">localdomain</domain>
</domain-config> </domain-config>
<debug-overrides>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config> </network-security-config>

View file

@ -10,11 +10,6 @@ package io.element.android.appconfig
object AuthenticationConfig { object AuthenticationConfig {
const val MATRIX_ORG_URL = "https://matrix.org" 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. * URL with some docs that explain what's sliding sync and how to add it to your home server.
*/ */

View file

@ -14,9 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.composable.PermanentChild import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext 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.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.logout.api.LogoutEntryPoint 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.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint 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.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias 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.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.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener 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 io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@ -107,7 +97,6 @@ class LoggedInFlowNode @AssistedInject constructor(
private val userProfileEntryPoint: UserProfileEntryPoint, private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint, private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val ftueService: FtueService, private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint, private val shareEntryPoint: ShareEntryPoint,
@ -115,7 +104,6 @@ class LoggedInFlowNode @AssistedInject constructor(
private val sendingQueue: SendQueues, private val sendingQueue: SendQueues,
private val logoutEntryPoint: LogoutEntryPoint, private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -133,7 +121,6 @@ class LoggedInFlowNode @AssistedInject constructor(
fun onOpenBugReport() fun onOpenBugReport()
} }
private val syncService = matrixClient.syncService()
private val loggedInFlowProcessor = LoggedInEventProcessor( private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher, snackbarDispatcher,
matrixClient.roomMembershipObserver(), matrixClient.roomMembershipObserver(),
@ -147,6 +134,7 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
@ -165,12 +153,6 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
}, },
onStop = {
coroutineScope.launch {
// Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
syncService.stopSync()
}
},
onDestroy = { onDestroy = {
appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
@ -178,7 +160,6 @@ class LoggedInFlowNode @AssistedInject constructor(
matrixClient.sessionVerificationService().setListener(null) matrixClient.sessionVerificationService().setListener(null)
} }
) )
observeSyncStateAndNetworkStatus()
setupSendingQueue() setupSendingQueue()
} }
@ -186,31 +167,6 @@ class LoggedInFlowNode @AssistedInject constructor(
sendingQueue.launchIn(lifecycleScope) 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 { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Placeholder : NavTarget data object Placeholder : NavTarget
@ -401,8 +357,8 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
NavTarget.CreateRoom -> { NavTarget.CreateRoom -> {
val callback = object : CreateRoomEntryPoint.Callback { val callback = object : CreateRoomEntryPoint.Callback {
override fun onSuccess(roomId: RoomId) { override fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
backstack.replace(NavTarget.Room(roomId.toRoomIdOrAlias())) backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames))
} }
} }
@ -462,9 +418,6 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
logoutEntryPoint.nodeBuilder(this, buildContext) logoutEntryPoint.nodeBuilder(this, buildContext)
.onSuccessfulLogoutPendingAction {
enableNativeSlidingSyncUseCase()
}
.callback(callback) .callback(callback)
.build() .build()
} }

View file

@ -27,7 +27,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.anvilannotations.ContributesNode 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.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootNavStateFlowFactory
@ -62,7 +62,7 @@ class RootFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val navStateFlowFactory: RootNavStateFlowFactory, private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixClientsHolder: MatrixClientsHolder, private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter, private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint, private val bugReportEntryPoint: BugReportEntryPoint,
private val viewFolderEntryPoint: ViewFolderEntryPoint, private val viewFolderEntryPoint: ViewFolderEntryPoint,
@ -78,14 +78,14 @@ class RootFlowNode @AssistedInject constructor(
plugins = plugins plugins = plugins
) { ) {
override fun onBuilt() { override fun onBuilt() {
matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap) matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
super.onBuilt() super.onBuilt()
observeNavState() observeNavState()
} }
override fun onSaveInstanceState(state: MutableSavedStateMap) { override fun onSaveInstanceState(state: MutableSavedStateMap) {
super.onSaveInstanceState(state) super.onSaveInstanceState(state)
matrixClientsHolder.saveIntoSavedState(state) matrixSessionCache.saveIntoSavedState(state)
navStateFlowFactory.saveIntoSavedState(state) navStateFlowFactory.saveIntoSavedState(state)
} }
@ -118,7 +118,7 @@ class RootFlowNode @AssistedInject constructor(
} }
private fun switchToNotLoggedInFlow() { private fun switchToNotLoggedInFlow() {
matrixClientsHolder.removeAll() matrixSessionCache.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow) backstack.safeRoot(NavTarget.NotLoggedInFlow)
} }
@ -131,7 +131,7 @@ class RootFlowNode @AssistedInject constructor(
onFailure: () -> Unit, onFailure: () -> Unit,
onSuccess: (SessionId) -> Unit, onSuccess: (SessionId) -> Unit,
) { ) {
matrixClientsHolder.getOrRestore(sessionId) matrixSessionCache.getOrRestore(sessionId)
.onSuccess { .onSuccess {
Timber.v("Succeed to restore session $sessionId") Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId) onSuccess(sessionId)
@ -200,7 +200,7 @@ class RootFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.LoggedInFlow -> { 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") Timber.w("Couldn't find any session, go through SplashScreen")
} }
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)

View file

@ -7,6 +7,7 @@
package io.element.android.appnav.di package io.element.android.appnav.di
import androidx.annotation.VisibleForTesting
import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap import com.bumble.appyx.core.state.SavedStateMap
import com.squareup.anvil.annotations.ContributesBinding 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" 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) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class MatrixClientsHolder @Inject constructor( class MatrixSessionCache @Inject constructor(
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val syncOrchestratorFactory: SyncOrchestrator.Factory,
) : MatrixClientProvider { ) : MatrixClientProvider {
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>() private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
private val restoreMutex = Mutex() private val restoreMutex = Mutex()
init { init {
authenticationService.listenToNewMatrixClients { matrixClient -> authenticationService.listenToNewMatrixClients { matrixClient ->
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
syncOrchestrator.start()
} }
} }
fun removeAll() { fun removeAll() {
sessionIdsToMatrixClient.clear() sessionIdsToMatrixSession.clear()
} }
fun remove(sessionId: SessionId) { fun remove(sessionId: SessionId) {
sessionIdsToMatrixClient.remove(sessionId) sessionIdsToMatrixSession.remove(sessionId)
} }
override fun getOrNull(sessionId: SessionId): MatrixClient? { override fun getOrNull(sessionId: SessionId): MatrixClient? {
return sessionIdsToMatrixClient[sessionId] return sessionIdsToMatrixSession[sessionId]?.matrixClient
} }
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> { override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> {
return restoreMutex.withLock { return restoreMutex.withLock {
when (val matrixClient = getOrNull(sessionId)) { when (val cached = getOrNull(sessionId)) {
null -> restore(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") @Suppress("UNCHECKED_CAST")
fun restoreWithSavedState(state: SavedStateMap?) { fun restoreWithSavedState(state: SavedStateMap?) {
Timber.d("Restore state") Timber.d("Restore state")
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) { if (state == null || sessionIdsToMatrixSession.isNotEmpty()) {
Timber.w("Restore with non-empty map") Timber.w("Restore with non-empty map")
return return
} }
@ -79,7 +96,7 @@ class MatrixClientsHolder @Inject constructor(
} }
fun saveIntoSavedState(state: MutableSavedStateMap) { fun saveIntoSavedState(state: MutableSavedStateMap) {
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray() val sessionKeys = sessionIdsToMatrixSession.keys.toTypedArray()
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}") Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
state[SAVE_INSTANCE_KEY] = sessionKeys state[SAVE_INSTANCE_KEY] = sessionKeys
} }
@ -88,10 +105,20 @@ class MatrixClientsHolder @Inject constructor(
Timber.d("Restore matrix session: $sessionId") Timber.d("Restore matrix session: $sessionId")
return authenticationService.restoreSession(sessionId) return authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient -> .onSuccess { matrixClient ->
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
syncOrchestrator.start()
} }
.onFailure { .onFailure {
Timber.e(it, "Fail to restore session") Timber.e(it, "Fail to restore session")
} }
} }
} }
private data class InMemoryMatrixSession(
val matrixClient: MatrixClient,
val syncOrchestrator: SyncOrchestrator,
)

View file

@ -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,
}

View file

@ -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.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.log.logger.LoggerTag 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.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService 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.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.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion 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.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline 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.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus 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.push.api.PushService
import io.element.android.libraries.pushproviders.api.RegistrationFailure import io.element.android.libraries.pushproviders.api.RegistrationFailure
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -51,7 +53,7 @@ class LoggedInPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase, private val buildMeta: BuildMeta,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
@Composable @Composable
override fun present(): LoggedInState { override fun present(): LoggedInState {
@ -60,6 +62,7 @@ class LoggedInPresenter @Inject constructor(
pushService.ignoreRegistrationError(matrixClient.sessionId) pushService.ignoreRegistrationError(matrixClient.sessionId)
}.collectAsState(initial = false) }.collectAsState(initial = false)
val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) } val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) }
LaunchedEffect(Unit) { preloadAccountManagementUrl() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
sessionVerificationService.sessionVerifiedStatus sessionVerificationService.sessionVerifiedStatus
.onEach { sessionVerifiedStatus -> .onEach { sessionVerifiedStatus ->
@ -103,12 +106,10 @@ class LoggedInPresenter @Inject constructor(
} }
} }
LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch { LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
forceNativeSlidingSyncMigration = matrixClient.forceNativeSlidingSyncMigration().getOrDefault(false) forceNativeSlidingSyncMigration = matrixClient.needsForcedNativeSlidingSyncMigration().getOrDefault(false)
} }
LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch { LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
// Enable native sliding sync if it wasn't already the case // Force the logout since Native Sliding Sync is already enforced by the SDK
enableNativeSlidingSyncUseCase()
// Then force the logout
matrixClient.logout(userInitiated = true, ignoreSdkError = true) matrixClient.logout(userInitiated = true, ignoreSdkError = true)
} }
} }
@ -119,20 +120,15 @@ class LoggedInPresenter @Inject constructor(
pusherRegistrationState = pusherRegistrationState.value, pusherRegistrationState = pusherRegistrationState.value,
ignoreRegistrationError = ignoreRegistrationError, ignoreRegistrationError = ignoreRegistrationError,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration, forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
appName = buildMeta.applicationName,
eventSink = ::handleEvent 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. // 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.forceNativeSlidingSyncMigration(): Result<Boolean> = runCatching { private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatching {
val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow() val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
if (currentSlidingSyncVersion == SlidingSyncVersion.Proxy) { currentSlidingSyncVersion == SlidingSyncVersion.Proxy
val availableSlidingSyncVersions = availableSlidingSyncVersions().getOrThrow()
availableSlidingSyncVersions.contains(SlidingSyncVersion.Native) &&
!availableSlidingSyncVersions.contains(SlidingSyncVersion.Proxy)
} else {
false
}
} }
private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState<AsyncData<Unit>>) { private suspend fun ensurePusherIsRegistered(pusherRegistrationState: MutableState<AsyncData<Unit>>) {
@ -209,4 +205,9 @@ class LoggedInPresenter @Inject constructor(
analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState)) analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState))
} }
} }
private fun CoroutineScope.preloadAccountManagementUrl() = launch {
matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList)
}
} }

View file

@ -14,5 +14,6 @@ data class LoggedInState(
val pusherRegistrationState: AsyncData<Unit>, val pusherRegistrationState: AsyncData<Unit>,
val ignoreRegistrationError: Boolean, val ignoreRegistrationError: Boolean,
val forceNativeSlidingSyncMigration: Boolean, val forceNativeSlidingSyncMigration: Boolean,
val appName: String,
val eventSink: (LoggedInEvents) -> Unit, val eventSink: (LoggedInEvents) -> Unit,
) )

View file

@ -24,10 +24,12 @@ fun aLoggedInState(
showSyncSpinner: Boolean = false, showSyncSpinner: Boolean = false,
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized, pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
forceNativeSlidingSyncMigration: Boolean = false, forceNativeSlidingSyncMigration: Boolean = false,
appName: String = "Element X",
) = LoggedInState( ) = LoggedInState(
showSyncSpinner = showSyncSpinner, showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState, pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false, ignoreRegistrationError = false,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration, forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
appName = appName,
eventSink = {}, eventSink = {},
) )

View file

@ -73,9 +73,12 @@ fun LoggedInView(
// Set the force migration dialog here so it's always displayed over every screen // Set the force migration dialog here so it's always displayed over every screen
if (state.forceNativeSlidingSyncMigration) { if (state.forceNativeSlidingSyncMigration) {
ForceNativeSlidingSyncMigrationDialog(onSubmit = { ForceNativeSlidingSyncMigrationDialog(
state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync) appName = state.appName,
}) onSubmit = {
state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
}
)
} }
} }
@ -98,11 +101,12 @@ private fun Throwable.getReason(): String? {
@Composable @Composable
private fun ForceNativeSlidingSyncMigrationDialog( private fun ForceNativeSlidingSyncMigrationDialog(
appName: String,
onSubmit: () -> Unit, onSubmit: () -> Unit,
) { ) {
ErrorDialog( ErrorDialog(
title = null, 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), submitText = stringResource(R.string.banner_migrate_to_native_sliding_sync_action),
onSubmit = onSubmit, onSubmit = onSubmit,
canDismiss = false, canDismiss = false,

View file

@ -32,7 +32,7 @@ class SendQueues @Inject constructor(
) { ) {
/** /**
* Launches the send queues retry mechanism in the given [coroutineScope]. * 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) @OptIn(FlowPreview::class)
fun launchIn(coroutineScope: CoroutineScope) { fun launchIn(coroutineScope: CoroutineScope) {

View file

@ -9,9 +9,7 @@ package io.element.android.appnav.room.joined
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -56,7 +54,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( 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) is RoomNavigationTarget.Messages -> NavTarget.Messages(input.focusedEventId)
RoomNavigationTarget.Details -> NavTarget.RoomDetails RoomNavigationTarget.Details -> NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings
@ -197,16 +195,6 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { 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() BackstackView()
} }
} }

View file

@ -9,7 +9,7 @@ package io.element.android.appnav.root
import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap 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.login.api.LoginUserStory
import io.element.android.features.preferences.api.CacheService import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService 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( class RootNavStateFlowFactory @Inject constructor(
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService, private val cacheService: CacheService,
private val matrixClientsHolder: MatrixClientsHolder, private val matrixSessionCache: MatrixSessionCache,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val loginUserStory: LoginUserStory, private val loginUserStory: LoginUserStory,
private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory, private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory,
@ -63,7 +63,7 @@ class RootNavStateFlowFactory @Inject constructor(
val initialCacheIndex = savedStateMap.getCacheIndexOrDefault() val initialCacheIndex = savedStateMap.getCacheIndexOrDefault()
return cacheService.clearedCacheEventFlow return cacheService.clearedCacheEventFlow
.onEach { sessionId -> .onEach { sessionId ->
matrixClientsHolder.remove(sessionId) matrixSessionCache.remove(sessionId)
// Ensure image loader will be recreated with the new MatrixClient // Ensure image loader will be recreated with the new MatrixClient
imageLoaderHolder.remove(sessionId) imageLoaderHolder.remove(sessionId)
// Also remove cached value for SessionPreferencesStore // Also remove cached value for SessionPreferencesStore

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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> <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> </resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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> <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> </resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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> <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> </resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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 lancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser lapplication."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Votre serveur daccueil ne prend plus en charge lancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser lapplication."</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Votre serveur daccueil ne prend plus en charge lancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser lapplication."</string>
</resources> </resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш домашний сервер больше не поддерживает старый протокол. Пожалуйста, выйдите и войдите в свою учётную запись снова, чтобы продолжить использование приложения."</string>
</resources> </resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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> <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> </resources>

View 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>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Log Out &amp; Upgrade"</string> <string name="banner_migrate_to_native_sliding_sync_action">"Log Out &amp; 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> <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> </resources>

View file

@ -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(),
)
}

View file

@ -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()
}
}

View file

@ -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(),
)
}
}
}

View file

@ -5,19 +5,20 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.appnav.loggedin 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.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties 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.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId 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.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState 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.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState 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.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID 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.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.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService 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.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService 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.api.PushService
import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.pushproviders.api.Distributor 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.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -59,10 +58,7 @@ class LoggedInPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createLoggedInPresenter() createLoggedInPresenter().test {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse() assertThat(initialState.showSyncSpinner).isFalse()
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue() 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 @Test
fun `present - show sync spinner`() = runTest { fun `present - show sync spinner`() = runTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val presenter = createLoggedInPresenter(roomListService, SyncState.Running) createLoggedInPresenter(
moleculeFlow(RecompositionMode.Immediate) { syncState = SyncState.Running,
presenter.present() matrixClient = FakeMatrixClient(roomListService = roomListService),
}.test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse() assertThat(initialState.showSyncSpinner).isFalse()
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show) roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
@ -92,18 +107,19 @@ class LoggedInPresenterTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val verificationService = FakeSessionVerificationService() val verificationService = FakeSessionVerificationService()
val encryptionService = FakeEncryptionService() val encryptionService = FakeEncryptionService()
val presenter = LoggedInPresenter( val buildMeta = aBuildMeta()
matrixClient = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService), LoggedInPresenter(
matrixClient = FakeMatrixClient(
roomListService = roomListService,
encryptionService = encryptionService,
),
syncService = FakeSyncService(initialSyncState = SyncState.Running), syncService = FakeSyncService(initialSyncState = SyncState.Running),
pushService = FakePushService(), pushService = FakePushService(),
sessionVerificationService = verificationService, sessionVerificationService = verificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = encryptionService, encryptionService = encryptionService,
enableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(InMemoryAppPreferencesStore(), this), buildMeta = buildMeta,
) ).test {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
@ -129,13 +145,10 @@ class LoggedInPresenterTest {
val verificationService = FakeSessionVerificationService( val verificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = verificationService, sessionVerificationService = verificationService,
) ).test {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull()) assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java) .isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java)
@ -155,13 +168,13 @@ class LoggedInPresenterTest {
val pushService = createFakePushService( val pushService = createFakePushService(
registerWithLambda = lambda, registerWithLambda = lambda,
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) matrixClient = FakeMatrixClient(
moleculeFlow(RecompositionMode.Immediate) { accountManagementUrlResult = { Result.success(null) },
presenter.present() ),
}.test { ).test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions() lambda.assertions()
@ -188,13 +201,13 @@ class LoggedInPresenterTest {
val pushService = createFakePushService( val pushService = createFakePushService(
registerWithLambda = lambda, registerWithLambda = lambda,
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) matrixClient = FakeMatrixClient(
moleculeFlow(RecompositionMode.Immediate) { accountManagementUrlResult = { Result.success(null) },
presenter.present() ),
}.test { ).test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue() assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
lambda.assertions() lambda.assertions()
@ -233,13 +246,13 @@ class LoggedInPresenterTest {
currentPushProvider = { pushProvider }, currentPushProvider = { pushProvider },
registerWithLambda = lambda, registerWithLambda = lambda,
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) matrixClient = FakeMatrixClient(
moleculeFlow(RecompositionMode.Immediate) { accountManagementUrlResult = { Result.success(null) },
presenter.present() ),
}.test { ).test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions() lambda.assertions()
@ -277,13 +290,13 @@ class LoggedInPresenterTest {
currentPushProvider = { pushProvider }, currentPushProvider = { pushProvider },
registerWithLambda = lambda, registerWithLambda = lambda,
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) matrixClient = FakeMatrixClient(
moleculeFlow(RecompositionMode.Immediate) { accountManagementUrlResult = { Result.success(null) },
presenter.present() ),
}.test { ).test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions() lambda.assertions()
@ -317,13 +330,10 @@ class LoggedInPresenterTest {
currentPushProvider = { pushProvider }, currentPushProvider = { pushProvider },
registerWithLambda = lambda, registerWithLambda = lambda,
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) ).test {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull()) assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java) .isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
@ -345,13 +355,10 @@ class LoggedInPresenterTest {
registerWithLambda = lambda, registerWithLambda = lambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) ).test {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull()) assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java) .isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java)
@ -394,13 +401,10 @@ class LoggedInPresenterTest {
registerWithLambda = lambda, registerWithLambda = lambda,
selectPushProviderLambda = selectPushProviderLambda, selectPushProviderLambda = selectPushProviderLambda,
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) ).test {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull()) assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java) .isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java)
@ -445,13 +449,13 @@ class LoggedInPresenterTest {
pushProvider1 = pushProvider1, pushProvider1 = pushProvider1,
registerWithLambda = lambda, registerWithLambda = lambda,
) )
val presenter = createLoggedInPresenter( createLoggedInPresenter(
pushService = pushService, pushService = pushService,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
) matrixClient = FakeMatrixClient(
moleculeFlow(RecompositionMode.Immediate) { accountManagementUrlResult = { Result.success(null) },
presenter.present() ),
}.test { ).test {
val finalState = awaitFirstItem() val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions().isCalledOnce() lambda.assertions().isCalledOnce()
@ -505,10 +509,9 @@ class LoggedInPresenterTest {
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) }, currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) }, availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
) )
val presenter = createLoggedInPresenter(matrixClient = matrixClient) createLoggedInPresenter(
moleculeFlow(RecompositionMode.Immediate) { matrixClient = matrixClient,
presenter.present() ).test {
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse() 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) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - LogoutAndMigrateToNativeSlidingSync enables native sliding sync and logs out the user`() = runTest { fun `present - LogoutAndMigrateToNativeSlidingSync logs out the user`() = runTest {
val logoutLambda = lambdaRecorder<Boolean, Boolean, String?> { userInitiated, ignoreSdkError -> val logoutLambda = lambdaRecorder<Boolean, Boolean, Unit> { userInitiated, ignoreSdkError ->
assertThat(userInitiated).isTrue() assertThat(userInitiated).isTrue()
assertThat(ignoreSdkError).isTrue() assertThat(ignoreSdkError).isTrue()
null
} }
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
).apply {
this.logoutLambda = logoutLambda this.logoutLambda = logoutLambda
} }
val appPreferencesStore = InMemoryAppPreferencesStore() createLoggedInPresenter(
val enableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(appPreferencesStore, this) matrixClient = matrixClient,
val presenter = createLoggedInPresenter(matrixClient = matrixClient, enableNativeSlidingSyncUseCase = enableNativeSlidingSyncUseCase) ).test {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync) initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
advanceUntilIdle() advanceUntilIdle()
assertThat(appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
assertThat(logoutLambda.assertions().isCalledOnce()) assertThat(logoutLambda.assertions().isCalledOnce())
} }
} }
@ -572,15 +551,16 @@ class LoggedInPresenterTest {
return awaitItem() return awaitItem()
} }
private fun TestScope.createLoggedInPresenter( private fun createLoggedInPresenter(
roomListService: RoomListService = FakeRoomListService(),
syncState: SyncState = SyncState.Running, syncState: SyncState = SyncState.Running,
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(), sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(), encryptionService: EncryptionService = FakeEncryptionService(),
pushService: PushService = FakePushService(), pushService: PushService = FakePushService(),
enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase = EnableNativeSlidingSyncUseCase(InMemoryAppPreferencesStore(), this), matrixClient: MatrixClient = FakeMatrixClient(
matrixClient: MatrixClient = FakeMatrixClient(roomListService = roomListService), accountManagementUrlResult = { Result.success(null) },
),
buildMeta: BuildMeta = aBuildMeta(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
@ -589,7 +569,7 @@ class LoggedInPresenterTest {
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = encryptionService, encryptionService = encryptionService,
enableNativeSlidingSyncUseCase = enableNativeSlidingSyncUseCase, buildMeta = buildMeta,
) )
} }
} }

View file

@ -166,14 +166,17 @@ allprojects {
// Register quality check tasks. // Register quality check tasks.
tasks.register("runQualityChecks") { tasks.register("runQualityChecks") {
dependsOn(":tests:konsist:testDebugUnitTest") dependsOn(":tests:konsist:testDebugUnitTest")
dependsOn(":app:lintGplayDebug")
project.subprojects { project.subprojects {
// For some reason `findByName("lint")` doesn't work tasks.findByPath("$path:lintDebug")?.let { dependsOn(it) }
tasks.findByPath("$path:lint")?.let { dependsOn(it) }
tasks.findByName("detekt")?.let { dependsOn(it) } tasks.findByName("detekt")?.let { dependsOn(it) }
tasks.findByName("ktlintCheck")?.let { dependsOn(it) } tasks.findByName("ktlintCheck")?.let { dependsOn(it) }
// tasks.findByName("buildHealth")?.let { dependsOn(it) } // tasks.findByName("buildHealth")?.let { dependsOn(it) }
} }
dependsOn(":app:knitCheck") 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 // Make sure to delete old screenshots before recording new ones

@ -1 +1 @@
Subproject commit b4f0427e3595049d39846aabcdc06e818f2e96ea Subproject commit 6d96bf58aec2ecc77b408858272cd64ec26e10d0

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -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>

View file

@ -8,9 +8,9 @@
package io.element.android.features.analytics.impl package io.element.android.features.analytics.impl
import android.app.Activity import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@ -34,7 +34,7 @@ class AnalyticsOptInNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not() val isDark = ElementTheme.isLightTheme.not()
val state = presenter.present() val state = presenter.present()
AnalyticsOptInView( AnalyticsOptInView(

View file

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
@ -111,7 +110,7 @@ private fun AnalyticsOptInHeader(
.padding(8.dp), .padding(8.dp),
style = ElementTheme.typography.fontBodyMdRegular style = ElementTheme.typography.fontBodyMdRegular
.copy( .copy(
color = MaterialTheme.colorScheme.secondary, color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
) )

View file

@ -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>

View file

@ -30,6 +30,7 @@ interface ElementCallEntryPoint {
* @param avatarUrl The avatar url of the room or DM. * @param avatarUrl The avatar url of the room or DM.
* @param timestamp The timestamp of the event that started the call. * @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 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( fun handleIncomingCall(
callType: CallType.RoomCall, callType: CallType.RoomCall,
@ -40,5 +41,6 @@ interface ElementCallEntryPoint {
avatarUrl: String?, avatarUrl: String?,
timestamp: Long, timestamp: Long,
notificationChannelId: String, notificationChannelId: String,
textContent: String?,
) )
} }

View file

@ -29,6 +29,7 @@ setupAnvil()
dependencies { dependencies {
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
@ -40,6 +41,7 @@ dependencies {
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit) implementation(libs.androidx.webkit)
implementation(libs.coil.compose) implementation(libs.coil.compose)
@ -59,6 +61,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View file

@ -43,6 +43,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
avatarUrl: String?, avatarUrl: String?,
timestamp: Long, timestamp: Long,
notificationChannelId: String, notificationChannelId: String,
textContent: String?,
) { ) {
val incomingCallNotificationData = CallNotificationData( val incomingCallNotificationData = CallNotificationData(
sessionId = callType.sessionId, sessionId = callType.sessionId,
@ -54,6 +55,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
timestamp = timestamp, timestamp = timestamp,
notificationChannelId = notificationChannelId, notificationChannelId = notificationChannelId,
textContent = textContent,
) )
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData) activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
} }

View file

@ -25,4 +25,5 @@ data class CallNotificationData(
val avatarUrl: String?, val avatarUrl: String?,
val notificationChannelId: String, val notificationChannelId: String,
val timestamp: Long, val timestamp: Long,
val textContent: String?,
) : Parcelable ) : Parcelable

View file

@ -11,7 +11,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.AudioManager import android.media.AudioManager
import android.media.RingtoneManager import android.provider.Settings
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person import androidx.core.app.Person
@ -63,6 +63,7 @@ class RingingCallNotificationCreator @Inject constructor(
roomAvatarUrl: String?, roomAvatarUrl: String?,
notificationChannelId: String, notificationChannelId: String,
timestamp: Long, timestamp: Long,
textContent: String?,
): Notification? { ): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
val imageLoader = imageLoaderHolder.get(matrixClient) val imageLoader = imageLoaderHolder.get(matrixClient)
@ -84,7 +85,8 @@ class RingingCallNotificationCreator @Inject constructor(
senderName = senderDisplayName, senderName = senderDisplayName,
avatarUrl = roomAvatarUrl, avatarUrl = roomAvatarUrl,
notificationChannelId = notificationChannelId, notificationChannelId = notificationChannelId,
timestamp = timestamp timestamp = timestamp,
textContent = textContent,
) )
val declineIntent = PendingIntentCompat.getBroadcast( val declineIntent = PendingIntentCompat.getBroadcast(
@ -107,8 +109,6 @@ class RingingCallNotificationCreator @Inject constructor(
false 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) return NotificationCompat.Builder(context, notificationChannelId)
.setSmallIcon(CommonDrawables.ic_notification_small) .setSmallIcon(CommonDrawables.ic_notification_small)
.setPriority(NotificationCompat.PRIORITY_MAX) .setPriority(NotificationCompat.PRIORITY_MAX)
@ -120,10 +120,12 @@ class RingingCallNotificationCreator @Inject constructor(
.setOngoing(true) .setOngoing(true)
.setShowWhen(false) .setShowWhen(false)
.apply { .apply {
if (ringtoneUri != null) { if (textContent != null) {
setSound(ringtoneUri, AudioManager.STREAM_RING) 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) .setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
.setContentIntent(answerIntent) .setContentIntent(answerIntent)
.setDeleteIntent(declineIntent) .setDeleteIntent(declineIntent)

View file

@ -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.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker 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 io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -58,9 +59,9 @@ class CallScreenPresenter @AssistedInject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val matrixClientsProvider: MatrixClientProvider, private val matrixClientsProvider: MatrixClientProvider,
private val screenTracker: ScreenTracker, private val screenTracker: ScreenTracker,
private val appCoroutineScope: CoroutineScope,
private val activeCallManager: ActiveCallManager, private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider, private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService,
) : Presenter<CallScreenState> { ) : Presenter<CallScreenState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -226,19 +227,13 @@ class CallScreenPresenter @AssistedInject constructor(
if (state == SyncState.Running) { if (state == SyncState.Running) {
client.notifyCallStartIfNeeded(callType.roomId) client.notifyCallStartIfNeeded(callType.roomId)
} else { } else {
client.syncService().startSync() appForegroundStateService.updateIsInCallState(true)
} }
} }
} }
onDispose { onDispose {
// We can't use the local coroutine scope here because it will be disposed before this effect // Make sure we mark the call as ended in the app state
appCoroutineScope.launch { appForegroundStateService.updateIsInCallState(false)
client.syncService().run {
if (syncState.value == SyncState.Running) {
stopSync()
}
}
}
} }
} }
} }

View file

@ -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.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser 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.Presenter
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
@ -61,6 +62,7 @@ class ElementCallActivity :
@Inject lateinit var callIntentDataParser: CallIntentDataParser @Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory @Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore @Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var enterpriseService: EnterpriseService
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter @Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
private lateinit var presenter: Presenter<CallScreenState> private lateinit var presenter: Presenter<CallScreenState>
@ -109,7 +111,10 @@ class ElementCallActivity :
setContent { setContent {
val pipState = pictureInPicturePresenter.present() val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState) ListenToAndroidEvents(pipState)
ElementThemeApp(appPreferencesStore) { ElementThemeApp(
appPreferencesStore = appPreferencesStore,
enterpriseService = enterpriseService,
) {
val state = presenter.present() val state = presenter.present()
eventSink = state.eventSink eventSink = state.eventSink
LaunchedEffect(state.isCallActive, state.isInWidgetMode) { LaunchedEffect(state.isCallActive, state.isInWidgetMode) {

View file

@ -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.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallState 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.architecture.bindings
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@ -47,6 +48,9 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject @Inject
lateinit var appPreferencesStore: AppPreferencesStore lateinit var appPreferencesStore: AppPreferencesStore
@Inject
lateinit var enterpriseService: EnterpriseService
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -64,7 +68,10 @@ class IncomingCallActivity : AppCompatActivity() {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (notificationData != null) { if (notificationData != null) {
setContent { setContent {
ElementThemeApp(appPreferencesStore) { ElementThemeApp(
appPreferencesStore = appPreferencesStore,
enterpriseService = enterpriseService,
) {
IncomingCallScreen( IncomingCallScreen(
notificationData = notificationData, notificationData = notificationData,
onAnswer = ::onAnswer, onAnswer = ::onAnswer,

View file

@ -100,7 +100,7 @@ internal fun IncomingCallScreen(
ActionButton( ActionButton(
size = 64.dp, size = 64.dp,
onClick = { onAnswer(notificationData) }, onClick = { onAnswer(notificationData) },
icon = CompoundIcons.VoiceCall(), icon = CompoundIcons.VoiceCallSolid(),
title = stringResource(CommonStrings.action_accept), title = stringResource(CommonStrings.action_accept),
backgroundColor = ElementTheme.colors.iconSuccessPrimary, backgroundColor = ElementTheme.colors.iconSuccessPrimary,
borderColor = ElementTheme.colors.borderSuccessSubtle borderColor = ElementTheme.colors.borderSuccessSubtle
@ -173,6 +173,7 @@ internal fun IncomingCallScreenPreview() = ElementPreview {
avatarUrl = null, avatarUrl = null,
notificationChannelId = "incoming_call", notificationChannelId = "incoming_call",
timestamp = 0L, timestamp = 0L,
textContent = null,
), ),
onAnswer = {}, onAnswer = {},
onCancel = {}, onCancel = {},

View file

@ -160,7 +160,8 @@ class DefaultActiveCallManager @Inject constructor(
senderDisplayName = notificationData.senderName ?: notificationData.senderId.value, senderDisplayName = notificationData.senderName ?: notificationData.senderId.value,
roomAvatarUrl = notificationData.avatarUrl, roomAvatarUrl = notificationData.avatarUrl,
notificationChannelId = notificationData.notificationChannelId, notificationChannelId = notificationData.notificationChannelId,
timestamp = notificationData.timestamp timestamp = notificationData.timestamp,
textContent = notificationData.textContent,
) ?: return ) ?: return
runCatching { runCatching {
notificationManagerCompat.notify( notificationManagerCompat.notify(

View file

@ -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>

View file

@ -54,6 +54,7 @@ class DefaultElementCallEntryPointTest {
avatarUrl = "avatarUrl", avatarUrl = "avatarUrl",
timestamp = 0, timestamp = 0,
notificationChannelId = "notificationChannelId", notificationChannelId = "notificationChannelId",
textContent = "textContent",
) )
registerIncomingCallLambda.assertions().isCalledOnce() registerIncomingCallLambda.assertions().isCalledOnce()

View file

@ -73,6 +73,7 @@ class RingingCallNotificationCreatorTest {
roomAvatarUrl = "https://example.com/avatar.jpg", roomAvatarUrl = "https://example.com/avatar.jpg",
notificationChannelId = "channelId", notificationChannelId = "channelId",
timestamp = 0L, timestamp = 0L,
textContent = "textContent",
) )
private fun createRingingCallNotificationCreator( private fun createRingingCallNotificationCreator(

View file

@ -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.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.analytics.test.FakeScreenTracker 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.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -243,7 +242,7 @@ class CallScreenPresenterTest {
} }
@Test @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 navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val startSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) } val startSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
@ -251,6 +250,7 @@ class CallScreenPresenterTest {
this.startSyncLambda = startSyncLambda this.startSyncLambda = startSyncLambda
} }
val matrixClient = FakeMatrixClient(syncService = syncService) val matrixClient = FakeMatrixClient(syncService = syncService)
val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
@ -258,34 +258,7 @@ class CallScreenPresenterTest {
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }), matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) appForegroundStateService = appForegroundStateService,
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 {},
) )
val hasRun = Mutex(true) val hasRun = Mutex(true)
val job = launch { 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 @Test
@ -354,6 +341,7 @@ class CallScreenPresenterTest {
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(), screenTracker: ScreenTracker = FakeScreenTracker(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
): CallScreenPresenter { ): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider { val userAgentProvider = object : UserAgentProvider {
override fun provide(): String { override fun provide(): String {
@ -369,10 +357,10 @@ class CallScreenPresenterTest {
clock = clock, clock = clock,
dispatchers = dispatchers, dispatchers = dispatchers,
matrixClientsProvider = matrixClientsProvider, matrixClientsProvider = matrixClientsProvider,
appCoroutineScope = this,
activeCallManager = activeCallManager, activeCallManager = activeCallManager,
screenTracker = screenTracker, screenTracker = screenTracker,
languageTagProvider = FakeLanguageTagProvider("en-US"), languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService,
) )
} }
} }

View file

@ -22,4 +22,5 @@ dependencies {
implementation(projects.features.call.impl) implementation(projects.features.call.impl)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test) implementation(projects.libraries.matrix.test)
implementation(projects.tests.testutils)
} }

View file

@ -30,6 +30,7 @@ fun aCallNotificationData(
avatarUrl: String? = AN_AVATAR_URL, avatarUrl: String? = AN_AVATAR_URL,
notificationChannelId: String = "channel_id", notificationChannelId: String = "channel_id",
timestamp: Long = 0L, timestamp: Long = 0L,
textContent: String? = null,
): CallNotificationData = CallNotificationData( ): CallNotificationData = CallNotificationData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
@ -40,4 +41,5 @@ fun aCallNotificationData(
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
notificationChannelId = notificationChannelId, notificationChannelId = notificationChannelId,
timestamp = timestamp, timestamp = timestamp,
textContent = textContent,
) )

View file

@ -11,10 +11,20 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint 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.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeElementCallEntryPoint( class FakeElementCallEntryPoint(
var startCallResult: (CallType) -> Unit = {}, var startCallResult: (CallType) -> Unit = { lambdaError() },
var handleIncomingCallResult: (CallType.RoomCall, EventId, UserId, String?, String?, String?, String) -> Unit = { _, _, _, _, _, _, _ -> } var handleIncomingCallResult: (
CallType.RoomCall,
EventId,
UserId,
String?,
String?,
String?,
String,
String?,
) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() }
) : ElementCallEntryPoint { ) : ElementCallEntryPoint {
override fun startCall(callType: CallType) { override fun startCall(callType: CallType) {
startCallResult(callType) startCallResult(callType)
@ -28,8 +38,18 @@ class FakeElementCallEntryPoint(
senderName: String?, senderName: String?,
avatarUrl: String?, avatarUrl: String?,
timestamp: Long, 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,
)
} }
} }

View file

@ -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

View file

@ -11,7 +11,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint 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 { interface CreateRoomEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@ -21,6 +21,6 @@ interface CreateRoomEntryPoint : FeatureEntryPoint {
} }
interface Callback : Plugin { interface Callback : Plugin {
fun onSuccess(roomId: RoomId) fun onOpenRoom(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>)
} }
} }

View file

@ -10,13 +10,19 @@ package io.element.android.features.createroom.api
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId 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 { interface StartDMAction {
/** /**
* Try to find an existing DM with the given user, or create one if none exists. * 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. * @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>>,
)
} }

View file

@ -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()
}
}

View file

@ -19,6 +19,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.features.createroom.impl.di.CreateRoomComponent import io.element.android.features.createroom.impl.di.CreateRoomComponent
@ -46,6 +47,7 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
private val component by lazy { private val component by lazy {
parent!!.bindings<CreateRoomComponent.ParentBindings>().createRoomComponentBuilder().build() parent!!.bindings<CreateRoomComponent.ParentBindings>().createRoomComponentBuilder().build()
} }
private val navigator = plugins<CreateRoomNavigator>().first()
override val daggerComponent: Any override val daggerComponent: Any
get() = component get() = component
@ -69,8 +71,7 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback)) createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback))
} }
NavTarget.ConfigureRoom -> { NavTarget.ConfigureRoom -> {
val callbacks = plugins<ConfigureRoomNode.Callback>() createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = listOf(navigator))
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = callbacks)
} }
} }
} }

View file

@ -8,25 +8,28 @@
package io.element.android.features.createroom.impl package io.element.android.features.createroom.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext 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.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.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.features.createroom.impl.root.CreateRoomRootNode
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode 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.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -47,35 +50,38 @@ class CreateRoomFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object NewRoom : NavTarget 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 { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Root -> { NavTarget.Root -> {
val callback = object : CreateRoomRootNode.Callback { createNode<CreateRoomRootNode>(buildContext = buildContext, plugins = listOf(navigator))
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))
} }
NavTarget.NewRoom -> { NavTarget.NewRoom -> {
val callback = object : ConfigureRoomNode.Callback { createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(navigator))
override fun onCreateRoomSuccess(roomId: RoomId) { }
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) } NavTarget.JoinByAddress -> {
} createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator))
}
createNode<ConfigureRoomFlowNode>(buildContext = buildContext, plugins = listOf(callback))
} }
} }
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
BackstackView() Box(modifier = modifier) {
BackstackView()
OverlayView(transitionHandler = remember { JumpToEndTransitionHandler() })
}
} }
} }

View file

@ -10,14 +10,15 @@ package io.element.android.features.createroom.impl
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.CreatedRoom 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.features.createroom.api.StartDMAction
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.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.StartDMResult
import io.element.android.libraries.matrix.api.room.startDM 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 io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject import javax.inject.Inject
@ -26,9 +27,13 @@ class DefaultStartDMAction @Inject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : StartDMAction { ) : 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 actionState.value = AsyncAction.Loading
when (val result = matrixClient.startDM(userId)) { when (val result = matrixClient.startDM(matrixUser.userId, createIfDmDoesNotExist)) {
is StartDMResult.Success -> { is StartDMResult.Success -> {
if (result.isNew) { if (result.isNew) {
analyticsService.capture(CreatedRoom(isDM = true)) analyticsService.capture(CreatedRoom(isDM = true))
@ -38,6 +43,9 @@ class DefaultStartDMAction @Inject constructor(
is StartDMResult.Failure -> { is StartDMResult.Failure -> {
actionState.value = AsyncAction.Failure(result.throwable) actionState.value = AsyncAction.Failure(result.throwable)
} }
StartDMResult.DmDoesNotExist -> {
actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser)
}
} }
} }
} }

View file

@ -18,8 +18,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode 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.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 import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(CreateRoomScope::class) @ContributesNode(CreateRoomScope::class)
@ -29,6 +30,8 @@ class ConfigureRoomNode @AssistedInject constructor(
private val presenter: ConfigureRoomPresenter, private val presenter: ConfigureRoomPresenter,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<CreateRoomNavigator>().first()
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onResume = { 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 @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -52,7 +47,9 @@ class ConfigureRoomNode @AssistedInject constructor(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackClick = this::navigateUp, onBackClick = this::navigateUp,
onCreateRoomSuccess = this::onCreateRoomSuccess, onCreateRoomSuccess = {
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
},
) )
} }
} }

View file

@ -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
}

View file

@ -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
)
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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
)

View file

@ -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)
}

View file

@ -8,9 +8,9 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import android.app.Activity import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@ -20,9 +20,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode 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.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope 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 import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -33,18 +34,7 @@ class CreateRoomRootNode @AssistedInject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val inviteFriendsUseCase: InviteFriendsUseCase, private val inviteFriendsUseCase: InviteFriendsUseCase,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { private val navigator = plugins<CreateRoomNavigator>().first()
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) }
}
init { init {
lifecycle.subscribe( lifecycle.subscribe(
@ -55,13 +45,16 @@ class CreateRoomRootNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val activity = LocalContext.current as Activity val activity = requireNotNull(LocalActivity.current)
CreateRoomRootView( CreateRoomRootView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onCloseClick = this::navigateUp, onCloseClick = this::navigateUp,
onNewRoomClick = ::onCreateNewRoom, onNewRoomClick = navigator::onCreateNewRoom,
onOpenDM = ::onStartChatSuccess, onOpenDM = {
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
},
onJoinByAddressClick = navigator::onShowJoinRoomByAddress,
onInviteFriendsClick = { invitePeople(activity) } onInviteFriendsClick = { invitePeople(activity) }
) )
} }

View file

@ -50,7 +50,11 @@ class CreateRoomRootPresenter @Inject constructor(
fun handleEvents(event: CreateRoomRootEvents) { fun handleEvents(event: CreateRoomRootEvents) {
when (event) { when (event) {
is CreateRoomRootEvents.StartDM -> localCoroutineScope.launch { 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 CreateRoomRootEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized
} }

View file

@ -8,6 +8,7 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.UserListState
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState import io.element.android.features.createroom.impl.userlist.aUserListState
@ -49,6 +50,9 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
recentDirectRooms = aRecentDirectRoomList() recentDirectRooms = aRecentDirectRoomList()
) )
), ),
aCreateRoomRootState(
startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()),
),
) )
} }

View file

@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -28,6 +27,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.R
import io.element.android.features.createroom.impl.components.UserListView import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.designsystem.components.async.AsyncActionView 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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId 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.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -54,6 +55,7 @@ fun CreateRoomRootView(
onNewRoomClick: () -> Unit, onNewRoomClick: () -> Unit,
onOpenDM: (RoomId) -> Unit, onOpenDM: (RoomId) -> Unit,
onInviteFriendsClick: () -> Unit, onInviteFriendsClick: () -> Unit,
onJoinByAddressClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -88,6 +90,7 @@ fun CreateRoomRootView(
state = state, state = state,
onNewRoomClick = onNewRoomClick, onNewRoomClick = onNewRoomClick,
onInvitePeopleClick = onInviteFriendsClick, onInvitePeopleClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinByAddressClick,
onDmClick = onOpenDM, onDmClick = onOpenDM,
) )
} }
@ -110,6 +113,19 @@ fun CreateRoomRootView(
?: state.eventSink(CreateRoomRootEvents.CancelStartDM) ?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
}, },
onErrorDismiss = { 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, state: CreateRoomRootState,
onNewRoomClick: () -> Unit, onNewRoomClick: () -> Unit,
onInvitePeopleClick: () -> Unit, onInvitePeopleClick: () -> Unit,
onJoinByAddressClick: () -> Unit,
onDmClick: (RoomId) -> Unit, onDmClick: (RoomId) -> Unit,
) { ) {
LazyColumn { LazyColumn {
@ -156,6 +173,13 @@ private fun CreateRoomActionButtonsList(
onClick = onInvitePeopleClick, 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()) { if (state.userListState.recentDirectRooms.isNotEmpty()) {
item { item {
ListSectionHeader( ListSectionHeader(
@ -196,7 +220,7 @@ private fun CreateRoomActionButton(
) { ) {
Icon( Icon(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.secondary, tint = ElementTheme.colors.iconSecondary,
resourceId = iconRes, resourceId = iconRes,
contentDescription = null, contentDescription = null,
) )
@ -216,6 +240,7 @@ internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProv
onCloseClick = {}, onCloseClick = {},
onNewRoomClick = {}, onNewRoomClick = {},
onOpenDM = {}, onOpenDM = {},
onJoinByAddressClick = {},
onInviteFriendsClick = {}, onInviteFriendsClick = {},
) )
} }

View file

@ -6,7 +6,7 @@
<string name="screen_create_room_private_option_description">"Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць."</string> <string name="screen_create_room_private_option_description">"Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць."</string>
<string name="screen_create_room_private_option_title">"Прыватны пакой (толькі па запрашэнні)"</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_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_anyone_option_title">"Хто заўгодна"</string>
<string name="screen_create_room_room_access_section_header">"Доступ у пакой"</string> <string name="screen_create_room_room_access_section_header">"Доступ у пакой"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Папрасіце далучыцца"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Папрасіце далучыцца"</string>

View file

@ -6,7 +6,6 @@
<string name="screen_create_room_private_option_description">"Съобщенията в тази стая са шифровани. Шифроването не може да бъде изключено впоследствие."</string> <string name="screen_create_room_private_option_description">"Съобщенията в тази стая са шифровани. Шифроването не може да бъде изключено впоследствие."</string>
<string name="screen_create_room_private_option_title">"Частна стая (само с покана)"</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_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_room_name_label">"Име на стаята"</string>
<string name="screen_create_room_title">"Създаване на стая"</string> <string name="screen_create_room_title">"Създаване на стая"</string>
<string name="screen_create_room_topic_label">"Тема за разговор (незадължително)"</string> <string name="screen_create_room_topic_label">"Тема за разговор (незадължително)"</string>

View file

@ -7,14 +7,14 @@
<string name="screen_create_room_private_option_title">"Privater Chatroom"</string> <string name="screen_create_room_private_option_title">"Privater Chatroom"</string>
<string name="screen_create_room_public_option_description">"Jeder kann diesen Chatroom finden. <string name="screen_create_room_public_option_description">"Jeder kann diesen Chatroom finden.
Sie können dies aber jederzeit in den Chatroomeinstellungen ändern."</string> 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_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_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_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_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_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_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_name_label">"Raumname"</string>
<string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chatrooms"</string> <string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chatrooms"</string>
<string name="screen_create_room_title">"Raum erstellen"</string> <string name="screen_create_room_title">"Raum erstellen"</string>

View file

@ -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_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στο δωμάτιο, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχθεί το αίτημα"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Αίτημα συμμετοχής"</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_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_name_label">"Όνομα δωματίου"</string>
<string name="screen_create_room_room_visibility_section_title">"Ορατότητα δωματίου"</string> <string name="screen_create_room_room_visibility_section_title">"Ορατότητα δωματίου"</string>
<string name="screen_create_room_title">"Δημιούργησε ένα δωμάτιο"</string> <string name="screen_create_room_title">"Δημιούργησε ένα δωμάτιο"</string>

View file

@ -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_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_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_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_room_name_label">"Nombre de la sala"</string>
<string name="screen_create_room_title">"Crear una sala"</string> <string name="screen_create_room_title">"Crear una sala"</string>
<string name="screen_create_room_topic_label">"Tema (opcional)"</string> <string name="screen_create_room_topic_label">"Tema (opcional)"</string>

View file

@ -6,7 +6,7 @@
<string name="screen_create_room_private_option_description">"پیام‌های این اتاق رمز شده‌اند. رمزنگاری نمی‌تواند از این پس تغییر کند."</string> <string name="screen_create_room_private_option_description">"پیام‌های این اتاق رمز شده‌اند. رمزنگاری نمی‌تواند از این پس تغییر کند."</string>
<string name="screen_create_room_private_option_title">"اتاق خصوصی (فقط دعوت)"</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_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_room_name_label">"نام اتاق"</string>
<string name="screen_create_room_title">"ایجاد اتاق"</string> <string name="screen_create_room_title">"ایجاد اتاق"</string>
<string name="screen_create_room_topic_label">"موضوع (اختیاری)"</string> <string name="screen_create_room_topic_label">"موضوع (اختیاری)"</string>

View file

@ -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_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_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_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_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_room_visibility_section_title">"Szoba láthatósága"</string>
<string name="screen_create_room_title">"Szoba létrehozása"</string> <string name="screen_create_room_title">"Szoba létrehozása"</string>

View file

@ -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_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_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_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_name_label">"Nama ruangan"</string>
<string name="screen_create_room_room_visibility_section_title">"Keterlihatan ruangan"</string> <string name="screen_create_room_room_visibility_section_title">"Keterlihatan ruangan"</string>
<string name="screen_create_room_title">"Buat ruangan"</string> <string name="screen_create_room_title">"Buat ruangan"</string>

View file

@ -7,7 +7,6 @@
<string name="screen_create_room_private_option_title">"კერძო ოთახი"</string> <string name="screen_create_room_private_option_title">"კერძო ოთახი"</string>
<string name="screen_create_room_public_option_description">"ყველას ამ ოთახის მოძებნა შეუძლია. <string name="screen_create_room_public_option_description">"ყველას ამ ოთახის მოძებნა შეუძლია.
თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."</string> თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."</string>
<string name="screen_create_room_public_option_title">"საჯარო ოთახი"</string>
<string name="screen_create_room_room_name_label">"ოთახის სახელი"</string> <string name="screen_create_room_room_name_label">"ოთახის სახელი"</string>
<string name="screen_create_room_title">"ოთახის შექმნა"</string> <string name="screen_create_room_title">"ოთახის შექმნა"</string>
<string name="screen_create_room_topic_label">"თემა (სურვილისამებრ)"</string> <string name="screen_create_room_topic_label">"თემა (სურვილისამებრ)"</string>

View file

@ -7,7 +7,6 @@
<string name="screen_create_room_private_option_title">"Sala privativa (somente por convite)"</string> <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. <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> 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_room_name_label">"Nome da sala"</string>
<string name="screen_create_room_title">"Criar uma sala"</string> <string name="screen_create_room_title">"Criar uma sala"</string>
<string name="screen_create_room_topic_label">"Tópico (opcional)"</string> <string name="screen_create_room_topic_label">"Tópico (opcional)"</string>

View file

@ -3,7 +3,7 @@
<string name="screen_create_room_action_create_room">"Nova sala"</string> <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_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_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_private_option_title">"Sala privada"</string>
<string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala. <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> Pode alterar esta opção nas definições da sala."</string>

View file

@ -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_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_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_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_room_name_label">"Numele camerei"</string>
<string name="screen_create_room_title">"Creați o cameră"</string> <string name="screen_create_room_title">"Creați o cameră"</string>
<string name="screen_create_room_topic_label">"Subiect (opțional)"</string> <string name="screen_create_room_topic_label">"Subiect (opțional)"</string>

View file

@ -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_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_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_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_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_title">"Skapa ett rum"</string>
<string name="screen_create_room_topic_label">"Ämne (valfritt)"</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> <string name="screen_start_chat_error_starting_chat">"Ett fel uppstod när du försökte starta en chatt"</string>

View file

@ -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>

View file

@ -7,7 +7,7 @@
<string name="screen_create_room_private_option_title">"Приватна кімната (тільки за запрошенням)"</string> <string name="screen_create_room_private_option_title">"Приватна кімната (тільки за запрошенням)"</string>
<string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату. <string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату.
Ви можете змінити це в будь-який час у налаштуваннях кімнати."</string> Ви можете змінити це в будь-який час у налаштуваннях кімнати."</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_description">"Будь-хто може приєднатися до цієї кімнати"</string>
<string name="screen_create_room_room_access_section_anyone_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_header">"Доступ до кімнати"</string>

View file

@ -6,7 +6,6 @@
<string name="screen_create_room_private_option_description">"Bu xonadagi xabarlar shifrlangan. Keyinchalik shifrlashni ochirib bolmaydi."</string> <string name="screen_create_room_private_option_description">"Bu xonadagi xabarlar shifrlangan. Keyinchalik shifrlashni ochirib bolmaydi."</string>
<string name="screen_create_room_private_option_title">"Shaxsiy xona (faqat taklif)"</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_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_room_name_label">"Xona nomi"</string>
<string name="screen_create_room_title">"Xonani yaratish"</string> <string name="screen_create_room_title">"Xonani yaratish"</string>
<string name="screen_create_room_topic_label">"Mavzu (ixtiyoriy)"</string> <string name="screen_create_room_topic_label">"Mavzu (ixtiyoriy)"</string>

View file

@ -7,7 +7,7 @@
<string name="screen_create_room_private_option_title">"私密聊天室"</string> <string name="screen_create_room_private_option_title">"私密聊天室"</string>
<string name="screen_create_room_public_option_description">"任何人都可以找到此聊天室。 <string name="screen_create_room_public_option_description">"任何人都可以找到此聊天室。
您隨時都可以在聊天室設定中變更此設定。"</string> 您隨時都可以在聊天室設定中變更此設定。"</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_description">"任何人都可以加入此聊天室"</string>
<string name="screen_create_room_room_access_section_anyone_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_header">"聊天室存取權"</string>

View file

@ -7,7 +7,7 @@
<string name="screen_create_room_private_option_title">"私有聊天室"</string> <string name="screen_create_room_private_option_title">"私有聊天室"</string>
<string name="screen_create_room_public_option_description">"任何人都能找到此聊天室。 <string name="screen_create_room_public_option_description">"任何人都能找到此聊天室。
你可以随时在聊天室设置中更改。"</string> 你可以随时在聊天室设置中更改。"</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_description">"任何人都可以加入此房间"</string>
<string name="screen_create_room_room_access_section_anyone_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_header">"房间访问权限"</string>

View file

@ -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_title">"Create a room"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</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_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> </resources>

View file

@ -10,13 +10,14 @@ package io.element.android.features.createroom.impl
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID 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_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.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.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -28,10 +29,12 @@ class DefaultStartDMActionTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(A_ROOM_ID) givenFindDmResult(A_ROOM_ID)
} }
val action = createStartDMAction(matrixClient) val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) 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(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
assertThat(analyticsService.capturedEvents).isEmpty()
} }
@Test @Test
@ -43,21 +46,38 @@ class DefaultStartDMActionTest {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService) val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) 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(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID))
assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true)) 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 @Test
fun `when dm creation fails, assert state is updated with given error`() = runTest { fun `when dm creation fails, assert state is updated with given error`() = runTest {
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenFindDmResult(null) givenFindDmResult(null)
givenCreateDmResult(Result.failure(A_THROWABLE)) givenCreateDmResult(Result.failure(A_THROWABLE))
} }
val action = createStartDMAction(matrixClient) val analyticsService = FakeAnalyticsService()
val action = createStartDMAction(matrixClient, analyticsService)
val state = mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) 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(state.value).isEqualTo(AsyncAction.Failure(A_THROWABLE))
assertThat(analyticsService.capturedEvents).isEmpty()
} }
private fun createStartDMAction( private fun createStartDMAction(

View file

@ -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()
}
}

View file

@ -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,
)
}
}

View file

@ -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)
}
}

View file

@ -7,16 +7,19 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.MutableState
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.api.StartDMAction
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter 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.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.test.FakeStartDMAction import io.element.android.features.createroom.test.FakeStartDMAction
import io.element.android.libraries.architecture.AsyncAction 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.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.matrix.test.core.aBuildMeta
import io.element.android.libraries.usersearch.test.FakeUserRepository import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule 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 kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -33,46 +39,130 @@ class CreateRoomRootPresenterTest {
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@Test @Test
fun `present - start DM action complete scenario`() = runTest { fun `present - start DM action failure scenario`() = runTest {
val startDMAction = FakeStartDMAction() 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) val presenter = createCreateRoomRootPresenter(startDMAction)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName) assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
assertThat(initialState.userListState.selectedUsers).isEmpty() assertThat(initialState.userListState.selectedUsers).isEmpty()
assertThat(initialState.userListState.isSearchActive).isFalse() assertThat(initialState.userListState.isSearchActive).isFalse()
assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse() assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
val matrixUser = MatrixUser(UserId("@name:domain")) 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)) initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(AsyncAction.Loading::class.java)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(startDMFailureResult) assertThat(state.startDmAction).isEqualTo(startDMFailureResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
state.eventSink(CreateRoomRootEvents.CancelStartDM) state.eventSink(CreateRoomRootEvents.CancelStartDM)
} }
// Success
startDMAction.givenExecuteResult(startDMSuccessResult)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(AsyncAction.Uninitialized) assertThat(state.startDmAction.isUninitialized()).isTrue()
state.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
} }
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 -> awaitItem().also { state ->
assertThat(state.startDmAction).isEqualTo(startDMSuccessResult) 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( private fun createCreateRoomRootPresenter(
startDMAction: StartDMAction = FakeStartDMAction(), startDMAction: StartDMAction = FakeStartDMAction(),
): CreateRoomRootPresenter { ): CreateRoomRootPresenter {

View file

@ -101,6 +101,21 @@ class CreateRoomRootViewTest {
rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick() 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( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreateRoomRootView(
@ -109,6 +124,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreat
onNewRoomClick: () -> Unit = EnsureNeverCalled(), onNewRoomClick: () -> Unit = EnsureNeverCalled(),
onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onInviteFriendsClick: () -> Unit = EnsureNeverCalled(), onInviteFriendsClick: () -> Unit = EnsureNeverCalled(),
onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
CreateRoomRootView( CreateRoomRootView(
@ -117,6 +133,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreat
onNewRoomClick = onNewRoomClick, onNewRoomClick = onNewRoomClick,
onOpenDM = onOpenDM, onOpenDM = onOpenDM,
onInviteFriendsClick = onInviteFriendsClick, onInviteFriendsClick = onInviteFriendsClick,
onJoinByAddressClick = onJoinRoomByAddressClick
) )
} }
} }

Some files were not shown because too many files have changed in this diff Show more