From 16a7fb33e4b01188bd4108e9a6093b635eb28cac Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 3 Jul 2024 10:19:31 +0200 Subject: [PATCH 01/71] Multi account - Do not reset analytics store on sign out. Else when 1 of many accounts is removed, the analytics opt in screen is displayed again. --- .../android/services/analytics/impl/DefaultAnalyticsService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt index 1df6c3ca6ae..385524464a5 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt @@ -85,7 +85,7 @@ class DefaultAnalyticsService( override suspend fun onSessionDeleted(userId: String) { // Delete the store - analyticsStore.reset() + // analyticsStore.reset() } private fun observeUserConsent() { From d48737283960c6dcfdf5286cfe3e1827c207a3ae Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 26 Aug 2025 21:02:35 +0200 Subject: [PATCH 02/71] Multi accounts - first implementation. --- .../appnav/LoggedInAppScopeFlowNode.kt | 5 ++ .../android/appnav/LoggedInFlowNode.kt | 5 ++ .../io/element/android/appnav/RootFlowNode.kt | 4 ++ .../features/home/impl/HomePresenter.kt | 15 ++++- .../preferences/api/PreferencesEntryPoint.kt | 1 + .../preferences/impl/PreferencesFlowNode.kt | 4 ++ .../impl/root/PreferencesRootEvents.kt | 3 + .../impl/root/PreferencesRootNode.kt | 6 ++ .../impl/root/PreferencesRootPresenter.kt | 34 ++++++++++ .../impl/root/PreferencesRootState.kt | 3 + .../impl/root/PreferencesRootStateProvider.kt | 3 + .../impl/root/PreferencesRootView.kt | 35 +++++++++- .../signedout/impl/SignedOutStateProvider.kt | 5 ++ .../components/avatar/AvatarSize.kt | 2 + .../libraries/featureflag/api/FeatureFlags.kt | 9 ++- .../libraries/matrix/impl/mapper/Session.kt | 8 +++ .../sessionstorage/api/SessionData.kt | 8 +++ .../sessionstorage/api/SessionStore.kt | 2 + .../session-storage/impl/build.gradle.kts | 1 + .../impl/DatabaseSessionStore.kt | 67 +++++++++++++++++-- .../sessionstorage/impl/SessionDataMapper.kt | 8 +++ .../libraries/matrix/session/SessionData.sq | 17 +++-- .../impl/src/main/sqldelight/migrations/9.sqm | 8 +++ .../impl/DatabaseSessionStoreTest.kt | 4 +- 24 files changed, 243 insertions(+), 14 deletions(-) create mode 100644 libraries/session-storage/impl/src/main/sqldelight/migrations/9.sqm diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index ef3d4b27b9c..60e7845e54c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -57,6 +57,7 @@ class LoggedInAppScopeFlowNode( ), DependencyInjectionGraphOwner { interface Callback : Plugin { fun onOpenBugReport() + fun onAddAccount() } @Parcelize @@ -83,6 +84,10 @@ class LoggedInAppScopeFlowNode( override fun onOpenBugReport() { plugins().forEach { it.onOpenBugReport() } } + + override fun onAddAccount() { + plugins().forEach { it.onAddAccount() } + } } return createNode(buildContext, listOf(callback)) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 2d08d5f6f5c..0e3a9cca742 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -139,6 +139,7 @@ class LoggedInFlowNode( ) { interface Callback : Plugin { fun onOpenBugReport() + fun onAddAccount() } private val loggedInFlowProcessor = LoggedInEventProcessor( @@ -395,6 +396,10 @@ class LoggedInFlowNode( } is NavTarget.Settings -> { val callback = object : PreferencesEntryPoint.Callback { + override fun onAddAccount() { + plugins().forEach { it.onAddAccount() } + } + override fun onOpenBugReport() { plugins().forEach { it.onOpenBugReport() } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 818e670e461..2bcc47428b7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -218,6 +218,10 @@ class RootFlowNode( override fun onOpenBugReport() { backstack.push(NavTarget.BugReport) } + + override fun onAddAccount() { + backstack.push(NavTarget.NotLoggedInFlow(null)) + } } createNode(buildContext, plugins = listOf(inputs, callback)) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index f0debd88abc..ef51cd7ada7 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.sessionstorage.api.SessionStore @Inject class HomePresenter( @@ -41,10 +42,11 @@ class HomePresenter( private val logoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, ) : Presenter { @Composable override fun present(): HomeState { - val matrixUser = client.userProfile.collectAsState() + val matrixUser by client.userProfile.collectAsState() val isOnline by syncService.isOnline.collectAsState() val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) val roomListState = roomListPresenter.present() @@ -62,6 +64,15 @@ class HomePresenter( // Force a refresh of the profile client.getUserProfile() } + LaunchedEffect(matrixUser) { + // Ensure that the profile is always up to date in our + // session storage when it changes + sessionStore.updateUserProfile( + sessionId = matrixUser.userId.value, + displayName = matrixUser.displayName, + avatarUrl = matrixUser.avatarUrl, + ) + } // Avatar indicator val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val directLogoutState = logoutPresenter.present() @@ -76,7 +87,7 @@ class HomePresenter( val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() return HomeState( - matrixUser = matrixUser.value, + matrixUser = matrixUser, showAvatarIndicator = showAvatarIndicator, hasNetworkConnection = isOnline, currentHomeNavigationBarItem = currentHomeNavigationBarItem, diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index f41d497b18c..4520c4b69fd 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -41,6 +41,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { + fun onAddAccount() fun onOpenBugReport() fun onSecureBackupClick() fun onOpenRoomNotificationSettings(roomId: RoomId) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 6b0d8a7becc..65644c30f4b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -117,6 +117,10 @@ class PreferencesFlowNode( return when (navTarget) { NavTarget.Root -> { val callback = object : PreferencesRootNode.Callback { + override fun onAddAccount() { + plugins().forEach { it.onAddAccount() } + } + override fun onOpenBugReport() { plugins().forEach { it.onOpenBugReport() } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt index ff74cebb517..87074ec7f92 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt @@ -7,6 +7,9 @@ package io.element.android.features.preferences.impl.root +import io.element.android.libraries.matrix.api.core.SessionId + sealed interface PreferencesRootEvents { data object OnVersionInfoClick : PreferencesRootEvents + data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 76febd58afe..f89b7982e93 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -34,6 +34,7 @@ class PreferencesRootNode( private val directLogoutView: DirectLogoutView, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { + fun onAddAccount() fun onOpenBugReport() fun onSecureBackupClick() fun onOpenAnalytics() @@ -48,6 +49,10 @@ class PreferencesRootNode( fun onOpenAccountDeactivation() } + private fun onAddAccount() { + plugins().forEach { it.onAddAccount() } + } + private fun onOpenBugReport() { plugins().forEach { it.onOpenBugReport() } } @@ -119,6 +124,7 @@ class PreferencesRootNode( state = state, modifier = modifier, onBackClick = this::navigateUp, + onAddAccountClick = this::onAddAccount, onOpenRageShake = this::onOpenBugReport, onOpenAnalytics = this::onOpenAnalytics, onOpenAbout = this::onOpenAbout, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index aad8086df6b..ebb9a5a8673 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -24,13 +24,21 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -45,6 +53,8 @@ class PreferencesRootPresenter( private val directLogoutPresenter: Presenter, private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, + private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, ) : Presenter { @Composable override fun present(): PreferencesRootState { @@ -55,6 +65,25 @@ class PreferencesRootPresenter( matrixClient.getUserProfile() } + val isMultiAccountEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount) + }.collectAsState(initial = false) + + val otherSessions by remember { + sessionStore.sessionsFlow().map { list -> + list + .filter { it.userId != matrixClient.sessionId.value } + .map { + MatrixUser( + userId = UserId(it.userId), + displayName = it.userDisplayName, + avatarUrl = it.userAvatarUrl, + ) + } + .toPersistentList() + } + }.collectAsState(initial = persistentListOf()) + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() } @@ -96,6 +125,9 @@ class PreferencesRootPresenter( is PreferencesRootEvents.OnVersionInfoClick -> { showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope) } + is PreferencesRootEvents.SwitchToSession -> coroutineScope.launch { + sessionStore.setLatestSession(event.sessionId.value) + } } } @@ -103,6 +135,8 @@ class PreferencesRootPresenter( myUser = matrixUser.value, version = versionFormatter.get(), deviceId = matrixClient.deviceId, + isMultiAccountEnabled = isMultiAccountEnabled, + otherSessions = otherSessions, showSecureBackup = !canVerifyUserSession, showSecureBackupBadge = showSecureBackupIndicator, accountManagementUrl = accountManagementUrl.value, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index ebe8aaf57f5..830c397c59f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -11,11 +11,14 @@ import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList data class PreferencesRootState( val myUser: MatrixUser, val version: String, val deviceId: DeviceId?, + val isMultiAccountEnabled: Boolean, + val otherSessions: ImmutableList, val showSecureBackup: Boolean, val showSecureBackupBadge: Boolean, val accountManagementUrl: String?, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 91b32fe12d3..437716a4c32 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf fun aPreferencesRootState( myUser: MatrixUser, @@ -20,6 +21,8 @@ fun aPreferencesRootState( myUser = myUser, version = "Version 1.1 (1)", deviceId = DeviceId("ILAKNDNASDLK"), + isMultiAccountEnabled = true, + otherSessions = persistentListOf(), showSecureBackup = true, showSecureBackupBadge = true, accountManagementUrl = "aUrl", diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 85c180ddacc..608d2652451 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -23,6 +23,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.user.UserPreferences import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -38,12 +39,14 @@ import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbar import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserProvider +import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.ui.strings.CommonStrings @Composable fun PreferencesRootView( state: PreferencesRootState, onBackClick: () -> Unit, + onAddAccountClick: () -> Unit, onSecureBackupClick: () -> Unit, onManageAccountClick: (url: String) -> Unit, onOpenAnalytics: () -> Unit, @@ -74,7 +77,12 @@ fun PreferencesRootView( }, user = state.myUser, ) - + if (state.isMultiAccountEnabled) { + MultiAccountSection( + state = state, + onAddAccountClick = onAddAccountClick, + ) + } // 'Manage my app' section ManageAppSection( state = state, @@ -114,6 +122,30 @@ fun PreferencesRootView( } } +@Composable +fun ColumnScope.MultiAccountSection( + state: PreferencesRootState, + onAddAccountClick: () -> Unit, +) { + state.otherSessions.forEach { matrixUser -> + MatrixUserRow( + modifier = Modifier.clickable { + state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId)) + }, + matrixUser = matrixUser, + avatarSize = AvatarSize.AccountItem, + ) + } + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), + headlineContent = { + Text("Add another account") + }, + onClick = onAddAccountClick, + ) + HorizontalDivider() +} + @Composable private fun ColumnScope.ManageAppSection( state: PreferencesRootState, @@ -286,6 +318,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) { PreferencesRootView( state = aPreferencesRootState(myUser = matrixUser), onBackClick = {}, + onAddAccountClick = {}, onOpenAnalytics = {}, onOpenRageShake = {}, onOpenDeveloperSettings = {}, diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt index 55e29c9e267..b246183127c 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.features.signedout.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionData +import java.util.Date open class SignedOutStateProvider : PreviewParameterProvider { override val values: Sequence @@ -43,5 +44,9 @@ private fun aSessionData( passphrase = null, sessionPath = "/a/path/to/a/session", cachePath = "/a/path/to/a/cache", + lastUsageIndex = 0, + lastUsageDate = Date(), + userDisplayName = null, + userAvatarUrl = null, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 9e9ebb61811..9f23c49def6 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -69,4 +69,6 @@ enum class AvatarSize(val dp: Dp) { OrganizationHeader(64.dp), SpaceHeader(64.dp), SpaceMember(24.dp), + + AccountItem(32.dp), } diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 43895bce166..732854a3f56 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -99,5 +99,12 @@ enum class FeatureFlags( description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.", defaultValue = { false }, isFinished = false, - ) + ), + MultiAccount( + key = "feature.multi_account", + title = "Multi account", + description = "Allow the application to connect to multiple accounts at the same time. Under active development!", + defaultValue = { false }, + isFinished = false, + ), } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt index 1d45c474706..938eb9bf493 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt @@ -34,6 +34,10 @@ internal fun Session.toSessionData( passphrase = passphrase, sessionPath = sessionPaths.fileDirectory.absolutePath, cachePath = sessionPaths.cacheDirectory.absolutePath, + lastUsageIndex = 0, + lastUsageDate = Date(), + userDisplayName = null, + userAvatarUrl = null, ) internal fun ExternalSession.toSessionData( @@ -55,4 +59,8 @@ internal fun ExternalSession.toSessionData( passphrase = passphrase, sessionPath = sessionPaths.fileDirectory.absolutePath, cachePath = sessionPaths.cacheDirectory.absolutePath, + lastUsageIndex = 0, + lastUsageDate = Date(), + userDisplayName = null, + userAvatarUrl = null, ) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index c33c0d88618..f82d2645d7c 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -39,4 +39,12 @@ data class SessionData( val sessionPath: String, /** The path to the cache data stored for the session in the filesystem. */ val cachePath: String, + /** The index of the last date of session usage. */ + val lastUsageIndex: Long, + /** The last date of session usage. */ + val lastUsageDate: Date, + /** The optional display name of the user. */ + val userDisplayName: String?, + /** The optional avatar URL of the user. */ + val userAvatarUrl: String?, ) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index a503ec0c288..b7083e332d7 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -20,9 +20,11 @@ interface SessionStore { * No op if userId is not found in DB. */ suspend fun updateData(sessionData: SessionData) + suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) suspend fun getSession(sessionId: String): SessionData? suspend fun getAllSessions(): List suspend fun getLatestSession(): SessionData? + suspend fun setLatestSession(sessionId: String) suspend fun removeSession(sessionId: String) } diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 6e9f480b426..8517f84c3be 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) + implementation(projects.services.toolbox.api) api(projects.libraries.sessionStorage.api) implementation(libs.sqldelight.driver.android) implementation(libs.sqlcipher) diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 69f1fc41a55..07e84ba3249 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -18,11 +18,13 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber +import java.util.Date @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) @@ -30,11 +32,12 @@ import timber.log.Timber class DatabaseSessionStore( private val database: SessionDatabase, private val dispatchers: CoroutineDispatchers, + private val systemClock: SystemClock, ) : SessionStore { private val sessionDataMutex = Mutex() override fun isLoggedIn(): Flow { - return database.sessionDataQueries.selectFirst() + return database.sessionDataQueries.selectLatest() .asFlow() .mapToOneOrNull(dispatchers.io) .map { @@ -51,7 +54,15 @@ class DatabaseSessionStore( override suspend fun storeData(sessionData: SessionData) { sessionDataMutex.withLock { - database.sessionDataQueries.insertSessionData(sessionData.toDbModel()) + val lastUsageIndex = getLastUsageIndex() + database.sessionDataQueries.insertSessionData( + sessionData + .copy( + lastUsageIndex = lastUsageIndex + 1, + lastUsageDate = Date(systemClock.epochMillis()), + ) + .toDbModel() + ) } } @@ -65,18 +76,66 @@ class DatabaseSessionStore( Timber.e("User ${sessionData.userId} not found in session database") return } - // Copy new data from SDK, but keep login timestamp + // Copy new data from SDK, but keep application data database.sessionDataQueries.updateSession( sessionData.copy( loginTimestamp = result.loginTimestamp, + lastUsageIndex = result.lastUsageIndex, + lastUsageDate = result.lastUsageDate, + userDisplayName = result.userDisplayName, + userAvatarUrl = result.userAvatarUrl, + ).toDbModel() + ) + } + } + + override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + if (result == null) { + Timber.e("User $sessionId not found in session database") + return + } + database.sessionDataQueries.updateSession( + result.copy( + userDisplayName = displayName, + userAvatarUrl = avatarUrl, + ).toDbModel() + ) + } + } + + override suspend fun setLatestSession(sessionId: String) { + val lastUsageIndex = getLatestSession()?.lastUsageIndex ?: 0 + val result = database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + if (result == null) { + Timber.e("User $sessionId not found in session database") + return + } + sessionDataMutex.withLock { + // Update lastUsageDate and lastSessionIndex of the session + database.sessionDataQueries.updateSession( + result.copy( + lastUsageIndex = lastUsageIndex + 1, + lastUsageDate = Date(systemClock.epochMillis()), ).toDbModel() ) } } + private fun getLastUsageIndex(): Long { + return database.sessionDataQueries.selectLatest() + .executeAsOneOrNull() + ?.lastUsageIndex ?: 0L + } + override suspend fun getLatestSession(): SessionData? { return sessionDataMutex.withLock { - database.sessionDataQueries.selectFirst() + database.sessionDataQueries.selectLatest() .executeAsOneOrNull() ?.toApiModel() } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt index 8dbbad2b71a..35a2690a3aa 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -27,6 +27,10 @@ internal fun SessionData.toDbModel(): DbSessionData { passphrase = passphrase, sessionPath = sessionPath, cachePath = cachePath, + lastUsageIndex = lastUsageIndex, + lastUsageDate = lastUsageDate.time, + userDisplayName = userDisplayName, + userAvatarUrl = userAvatarUrl, ) } @@ -45,5 +49,9 @@ internal fun DbSessionData.toApiModel(): SessionData { passphrase = passphrase, sessionPath = sessionPath, cachePath = cachePath, + lastUsageIndex = lastUsageIndex, + lastUsageDate = Date(lastUsageDate), + userDisplayName = userDisplayName, + userAvatarUrl = userAvatarUrl, ) } diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index 6e4c8174752..b52d0962b29 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -27,15 +27,24 @@ CREATE TABLE SessionData ( -- added in version 6 sessionPath TEXT NOT NULL DEFAULT "", -- added in version 9 - cachePath TEXT NOT NULL DEFAULT "" + cachePath TEXT NOT NULL DEFAULT "", + -- index of the last usage session. Each time the current session change, the index of the current + -- session is incremented to the max value + 1 so it become the current session + lastUsageIndex INTEGER NOT NULL DEFAULT 0, + -- informative last usage date + lastUsageDate INTEGER NOT NULL DEFAULT 0, + -- user display name + userDisplayName TEXT, + -- user avatar url + userAvatarUrl TEXT ); -selectFirst: -SELECT * FROM SessionData LIMIT 1; +selectLatest: +SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1; selectAll: -SELECT * FROM SessionData; +SELECT * FROM SessionData ORDER BY lastUsageIndex DESC; selectByUserId: SELECT * FROM SessionData WHERE userId = ?; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/9.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/9.sqm new file mode 100644 index 00000000000..d072a75b08d --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/9.sqm @@ -0,0 +1,8 @@ +-- Migrate DB from version 9 +-- Add lastUsageDate and lastUsageIndex so we can restore the last session and switch to another one +-- Add display name and avatar url of the user so that we can display a list of accounts. + +ALTER TABLE SessionData ADD COLUMN lastUsageDate INTEGER NOT NULL DEFAULT 0; +ALTER TABLE SessionData ADD COLUMN lastUsageIndex INTEGER NOT NULL DEFAULT 0; +ALTER TABLE SessionData ADD COLUMN userDisplayName TEXT; +ALTER TABLE SessionData ADD COLUMN userAvatarUrl TEXT; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index 0d74dde16ab..cd508277eb8 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -45,11 +45,11 @@ class DatabaseSessionStoreTest { @Test fun `storeData persists the SessionData into the DB`() = runTest { - assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isNull() + assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isNull() databaseSessionStore.storeData(aSessionData.toApiModel()) - assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData) + assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData) assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1) } From 8ebfadd5117151992b07715bc50e7bb5ade6410c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 27 Aug 2025 10:14:25 +0200 Subject: [PATCH 03/71] Multi accounts - Prevent user from logging twice with the same account --- .../login/impl/login/LoginModeView.kt | 8 +++++ .../api/auth/AuthenticationException.kt | 1 + .../impl/auth/AuthenticationException.kt | 1 + .../auth/RustMatrixAuthenticationService.kt | 33 +++++++++++++++---- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt index 73127281bc2..307e82a863c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.LocalBuildMeta +import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.ui.strings.CommonStrings @@ -89,6 +90,13 @@ fun LoginModeView( onSubmit = onClearError, ) } + is AuthenticationException.AccountAlreadyLoggedIn -> { + // TODO i18n + ErrorDialog( + content = "You're already logged in on this device as ${error.message}.", + onSubmit = onClearError, + ) + } else -> { ErrorDialog( content = stringResource(CommonStrings.error_unknown), diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt index ef73edfaf50..03e8d571507 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.api.auth sealed class AuthenticationException(message: String) : Exception(message) { + class AccountAlreadyLoggedIn(userId: String) : AuthenticationException(userId) class InvalidServerName(message: String) : AuthenticationException(message) class SlidingSyncVersion(message: String) : AuthenticationException(message) class Oidc(message: String) : AuthenticationException(message) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt index 7175913dad4..05eb4c4d5f7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -14,6 +14,7 @@ import org.matrix.rustcomponents.sdk.OidcException fun Throwable.mapAuthenticationException(): AuthenticationException { val message = this.message ?: "Unknown error" return when (this) { + is AuthenticationException -> this is ClientBuildException -> when (this) { is ClientBuildException.Generic -> AuthenticationException.Generic(message) is ClientBuildException.InvalidServerName -> AuthenticationException.InvalidServerName(message) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index be2263eb869..65c72b7676f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails @@ -149,6 +150,8 @@ class RustMatrixAuthenticationService( val client = currentClient ?: error("You need to call `setHomeserver()` first") val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") client.login(username, password, "Element X Android", null) + // Ensure that the user is not already logged in with the same account + ensureNotAlreadyLoggedIn(client) val sessionData = client.session() .toSessionData( isTokenValid = true, @@ -237,17 +240,19 @@ class RustMatrixAuthenticationService( val client = currentClient ?: error("You need to call `setHomeserver()` first") val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") client.loginWithOidcCallback(callbackUrl) + + // Free the pending data since we won't use it to abort the flow anymore + pendingOAuthAuthorizationData?.close() + pendingOAuthAuthorizationData = null + + // Ensure that the user is not already logged in with the same account + ensureNotAlreadyLoggedIn(client) val sessionData = client.session().toSessionData( isTokenValid = true, loginType = LoginType.OIDC, passphrase = pendingPassphrase, sessionPaths = currentSessionPaths, ) - - // Free the pending data since we won't use it to abort the flow anymore - pendingOAuthAuthorizationData?.close() - pendingOAuthAuthorizationData = null - val matrixClient = rustMatrixClientFactory.create(client) newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.storeData(sessionData) @@ -263,6 +268,21 @@ class RustMatrixAuthenticationService( } } + @Throws(AuthenticationException.AccountAlreadyLoggedIn::class) + private suspend fun ensureNotAlreadyLoggedIn(client: Client) { + val newUserId = client.userId() + val accountAlreadyLoggedIn = sessionStore.getAllSessions().any { + it.userId == newUserId + } + if (accountAlreadyLoggedIn) { + // Sign out the client, ignoring any error + runCatchingExceptions { + client.logout() + } + throw AuthenticationException.AccountAlreadyLoggedIn(newUserId) + } + } + override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) = withContext(coroutineDispatchers.io) { val sdkQrCodeLoginData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData @@ -285,7 +305,8 @@ class RustMatrixAuthenticationService( oidcConfiguration = oidcConfiguration, progressListener = progressListener, ) - + // Ensure that the user is not already logged in with the same account + ensureNotAlreadyLoggedIn(client) val sessionData = client.session() .toSessionData( isTokenValid = true, From 0859fc3fcdde9add688746268e86ca5c734d0843 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 27 Aug 2025 10:40:35 +0200 Subject: [PATCH 04/71] Multi accounts - ignore automatic GoBack in case of error. --- .../element/android/features/login/impl/login/LoginHelper.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt index 70a0d977810..14bc64bdf34 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt @@ -94,6 +94,11 @@ class LoginHelper( } private suspend fun onOidcAction(oidcAction: OidcAction) { + if (oidcAction is OidcAction.GoBack && loginModeState.value !is AsyncData.Loading) { + // Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode. + // This can happen if there is an error, for instance attempt to login again on the same account. + return + } loginModeState.value = AsyncData.Loading() when (oidcAction) { OidcAction.GoBack -> { From f51b392ab3ee101716b0527b1a3ecfc95d9794a2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 27 Aug 2025 11:09:51 +0200 Subject: [PATCH 05/71] Multi accounts - update first view when adding an account. --- .../impl/screens/onboarding/OnBoardingNode.kt | 1 + .../screens/onboarding/OnBoardingPresenter.kt | 7 + .../screens/onboarding/OnBoardingState.kt | 1 + .../onboarding/OnBoardingStateProvider.kt | 7 + .../impl/screens/onboarding/OnBoardingView.kt | 131 +++++++++++++----- 5 files changed, 109 insertions(+), 38 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt index 29fed1adbd5..47d8ce4b63e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -97,6 +97,7 @@ class OnBoardingNode( onNeedLoginPassword = ::onLoginPasswordNeeded, onLearnMoreClick = { openLearnMorePage(context) }, onCreateAccountContinue = ::onCreateAccountContinue, + onBackPressed = ::navigateUp, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index cec124d5875..45be83224e4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -27,6 +27,7 @@ import io.element.android.features.login.impl.login.LoginHelper import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.ui.utils.MultipleTapToUnlock @Inject @@ -38,6 +39,7 @@ class OnBoardingPresenter( private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val loginHelper: LoginHelper, private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider, + private val sessionStore: SessionStore, ) : Presenter { @AssistedFactory interface Factory { @@ -86,6 +88,10 @@ class OnBoardingPresenter( val onBoardingLogoResId = remember { onBoardingLogoResIdProvider.get() } + val isAddingAccount by produceState(initialValue = false) { + // We are adding an account if there is at least one session already stored + value = sessionStore.getAllSessions().isNotEmpty() + } val loginMode by loginHelper.collectLoginMode() @@ -109,6 +115,7 @@ class OnBoardingPresenter( } return OnBoardingState( + isAddingAccount = isAddingAccount, productionApplicationName = buildMeta.productionApplicationName, defaultAccountProvider = defaultAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt index c2896d4ea7d..ae5bb79eb5b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -12,6 +12,7 @@ import io.element.android.features.login.impl.login.LoginMode import io.element.android.libraries.architecture.AsyncData data class OnBoardingState( + val isAddingAccount: Boolean, val productionApplicationName: String, val defaultAccountProvider: String?, val mustChooseAccountProvider: Boolean, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt index cc41e64480a..2eb9bfb3012 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt @@ -23,10 +23,16 @@ open class OnBoardingStateProvider : PreviewParameterProvider { anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true), anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true), anOnBoardingState(customLogoResId = R.drawable.sample_background), + anOnBoardingState( + isAddingAccount = true, + canLoginWithQrCode = true, + canCreateAccount = true, + ), ) } fun anOnBoardingState( + isAddingAccount: Boolean = false, productionApplicationName: String = "Element", defaultAccountProvider: String? = null, mustChooseAccountProvider: Boolean = false, @@ -39,6 +45,7 @@ fun anOnBoardingState( loginMode: AsyncData = AsyncData.Uninitialized, eventSink: (OnBoardingEvents) -> Unit = {}, ) = OnBoardingState( + isAddingAccount = isAddingAccount, productionApplicationName = productionApplicationName, defaultAccountProvider = defaultAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index 4c44ee132ae..27d736c0aba 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -38,7 +38,9 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage +import io.element.android.libraries.designsystem.components.BigIcon 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 @@ -58,6 +60,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun OnBoardingView( state: OnBoardingState, + onBackPressed: () -> Unit, onSignInWithQrCode: () -> Unit, onSignIn: (mustChooseAccountProvider: Boolean) -> Unit, onCreateAccount: () -> Unit, @@ -67,6 +70,52 @@ fun OnBoardingView( onCreateAccountContinue: (url: String) -> Unit, onReportProblem: () -> Unit, modifier: Modifier = Modifier, +) { + val loginView = @Composable { + LoginModeView( + loginMode = state.loginMode, + onClearError = { + state.eventSink(OnBoardingEvents.ClearError) + }, + onLearnMoreClick = onLearnMoreClick, + onOidcDetails = onOidcDetails, + onNeedLoginPassword = onNeedLoginPassword, + onCreateAccountContinue = onCreateAccountContinue, + ) + } + val buttons = @Composable { + OnBoardingButtons( + state = state, + onSignInWithQrCode = onSignInWithQrCode, + onSignIn = onSignIn, + onCreateAccount = onCreateAccount, + onReportProblem = onReportProblem, + ) + } + + if (state.isAddingAccount) { + AddOtherAccountScaffold( + modifier = modifier, + loginView = loginView, + buttons = buttons, + onBackPressed = onBackPressed, + ) + } else { + AddFirstAccountScaffold( + modifier = modifier, + state = state, + loginView = loginView, + buttons = buttons, + ) + } +} + +@Composable +private fun AddFirstAccountScaffold( + state: OnBoardingState, + loginView: @Composable () -> Unit, + buttons: @Composable () -> Unit, + modifier: Modifier = Modifier, ) { OnBoardingPage( modifier = modifier, @@ -79,29 +128,32 @@ fun OnBoardingView( } else { OnBoardingContent(state = state) } - LoginModeView( - loginMode = state.loginMode, - onClearError = { - state.eventSink(OnBoardingEvents.ClearError) - }, - onLearnMoreClick = onLearnMoreClick, - onOidcDetails = onOidcDetails, - onNeedLoginPassword = onNeedLoginPassword, - onCreateAccountContinue = onCreateAccountContinue, - ) + loginView() }, footer = { - OnBoardingButtons( - state = state, - onSignInWithQrCode = onSignInWithQrCode, - onSignIn = onSignIn, - onCreateAccount = onCreateAccount, - onReportProblem = onReportProblem, - ) + buttons() } ) } +@Composable +private fun AddOtherAccountScaffold( + loginView: @Composable () -> Unit, + buttons: @Composable () -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + modifier = modifier, + // TODO i18n + title = "Add account", + iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + buttons = { buttons() }, + content = loginView, + onBackClick = onBackPressed, + ) +} + @Composable private fun OnBoardingContent(state: OnBoardingState) { Box( @@ -226,27 +278,29 @@ private fun OnBoardingButtons( .fillMaxWidth() ) } - if (state.canReportBug) { - // Add a report problem text button. Use a Text since we need a special theme here. - Text( - modifier = Modifier - .clickable(onClick = onReportProblem) - .padding(16.dp), - text = stringResource(id = CommonStrings.common_report_a_problem), - style = ElementTheme.typography.fontBodySmRegular, - color = ElementTheme.colors.textSecondary, - ) - } else { - Text( - modifier = Modifier - .clickable { - state.eventSink(OnBoardingEvents.OnVersionClick) - } - .padding(16.dp), - text = stringResource(id = R.string.screen_onboarding_app_version, state.version), - style = ElementTheme.typography.fontBodySmRegular, - color = ElementTheme.colors.textSecondary, - ) + if (state.isAddingAccount.not()) { + if (state.canReportBug) { + // Add a report problem text button. Use a Text since we need a special theme here. + Text( + modifier = Modifier + .clickable(onClick = onReportProblem) + .padding(16.dp), + text = stringResource(id = CommonStrings.common_report_a_problem), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } else { + Text( + modifier = Modifier + .clickable { + state.eventSink(OnBoardingEvents.OnVersionClick) + } + .padding(16.dp), + text = stringResource(id = R.string.screen_onboarding_app_version, state.version), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } } } } @@ -258,6 +312,7 @@ internal fun OnBoardingViewPreview( ) = ElementPreview { OnBoardingView( state = state, + onBackPressed = {}, onSignInWithQrCode = {}, onSignIn = {}, onCreateAccount = {}, From 8ea5d0e9c52a2b24d7558ace1500b4f896e06b7f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 27 Aug 2025 16:48:45 +0200 Subject: [PATCH 06/71] Rename method storeData to addSession. --- .../matrix/impl/auth/RustMatrixAuthenticationService.kt | 8 ++++---- .../impl/auth/RustMatrixAuthenticationServiceTest.kt | 2 +- .../android/libraries/sessionstorage/api/SessionStore.kt | 2 +- .../libraries/sessionstorage/impl/DatabaseSessionStore.kt | 2 +- .../sessionstorage/impl/DatabaseSessionStoreTest.kt | 4 ++-- .../impl/observer/DefaultSessionObserverTest.kt | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 65c72b7676f..3a991c6f648 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -161,7 +161,7 @@ class RustMatrixAuthenticationService( ) val matrixClient = rustMatrixClientFactory.create(client) newMatrixClientObservers.forEach { it.invoke(matrixClient) } - sessionStore.storeData(sessionData) + sessionStore.addSession(sessionData) // Clean up the strong reference held here since it's no longer necessary currentClient = null @@ -185,7 +185,7 @@ class RustMatrixAuthenticationService( sessionPaths = currentSessionPaths, ) clear() - sessionStore.storeData(sessionData) + sessionStore.addSession(sessionData) SessionId(sessionData.userId) } } @@ -255,7 +255,7 @@ class RustMatrixAuthenticationService( ) val matrixClient = rustMatrixClientFactory.create(client) newMatrixClientObservers.forEach { it.invoke(matrixClient) } - sessionStore.storeData(sessionData) + sessionStore.addSession(sessionData) // Clean up the strong reference held here since it's no longer necessary currentClient = null @@ -316,7 +316,7 @@ class RustMatrixAuthenticationService( ) val matrixClient = rustMatrixClientFactory.create(client) newMatrixClientObservers.forEach { it.invoke(matrixClient) } - sessionStore.storeData(sessionData) + sessionStore.addSession(sessionData) // Clean up the strong reference held here since it's no longer necessary currentClient = null diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index 175e1722d9b..3ec47488b73 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -30,7 +30,7 @@ class RustMatrixAuthenticationServiceTest { sessionStore = sessionStore, ) assertThat(sut.getLatestSessionId()).isNull() - sessionStore.storeData(aSessionData(sessionId = "@alice:server.org")) + sessionStore.addSession(aSessionData(sessionId = "@alice:server.org")) assertThat(sut.getLatestSessionId()).isEqualTo(SessionId("@alice:server.org")) } diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index b7083e332d7..10dfd49e759 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.map interface SessionStore { fun isLoggedIn(): Flow fun sessionsFlow(): Flow> - suspend fun storeData(sessionData: SessionData) + suspend fun addSession(sessionData: SessionData) /** * Will update the session data matching the userId, except the value of loginTimestamp. diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 07e84ba3249..60272f0eeed 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -52,7 +52,7 @@ class DatabaseSessionStore( } } - override suspend fun storeData(sessionData: SessionData) { + override suspend fun addSession(sessionData: SessionData) { sessionDataMutex.withLock { val lastUsageIndex = getLastUsageIndex() database.sessionDataQueries.insertSessionData( diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index cd508277eb8..0ab3950c4c4 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -44,10 +44,10 @@ class DatabaseSessionStoreTest { } @Test - fun `storeData persists the SessionData into the DB`() = runTest { + fun `addSession persists the SessionData into the DB`() = runTest { assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isNull() - databaseSessionStore.storeData(aSessionData.toApiModel()) + databaseSessionStore.addSession(aSessionData.toApiModel()) assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData) assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt index 35d7fea042b..8b0184fdc79 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt @@ -51,7 +51,7 @@ import org.junit.Test runCurrent() val listener = TestSessionListener() sut.addListener(listener) - databaseSessionStore.storeData(sessionData.toApiModel()) + databaseSessionStore.addSession(sessionData.toApiModel()) listener.assertEvents(TestSessionListener.Event.Created(sessionData.userId)) sut.removeListener(listener) coroutineContext.cancelChildren() @@ -64,7 +64,7 @@ import org.junit.Test runCurrent() val listener = TestSessionListener() sut.addListener(listener) - databaseSessionStore.storeData(sessionData.toApiModel()) + databaseSessionStore.addSession(sessionData.toApiModel()) listener.assertEvents(TestSessionListener.Event.Created(sessionData.userId)) databaseSessionStore.removeSession(sessionData.userId) listener.assertEvents( From e9987efab978b9b3644a828de450feae2c282dec Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 27 Aug 2025 17:09:55 +0200 Subject: [PATCH 07/71] Multi accounts - handle account switch when coming from a notification --- .../main/kotlin/io/element/android/appnav/RootFlowNode.kt | 5 ++++- .../libraries/sessionstorage/impl/DatabaseSessionStore.kt | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 2bcc47428b7..291b2fbfafd 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -54,6 +54,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.oidc.api.OidcAction import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -76,6 +77,7 @@ class RootFlowNode( private val intentResolver: IntentResolver, private val oidcActionFlow: OidcActionFlow, private val bugReporter: BugReporter, + private val sessionStore: SessionStore, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.SplashScreen, @@ -365,7 +367,8 @@ class RootFlowNode( // [sessionId] will be null for permalink. private suspend fun attachSession(sessionId: SessionId?): LoggedInFlowNode { - // TODO handle multi-session + // Ensure that the session is the latest one + sessionId?.let { sessionStore.setLatestSession(it.value) } return waitForChildAttached { navTarget -> navTarget is NavTarget.LoggedInFlow && (sessionId == null || navTarget.sessionId == sessionId) } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 60272f0eeed..78a504d0eb0 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -108,6 +108,11 @@ class DatabaseSessionStore( } override suspend fun setLatestSession(sessionId: String) { + val latestSession = getLatestSession() + if (latestSession?.userId == sessionId) { + // Already the latest session + return + } val lastUsageIndex = getLatestSession()?.lastUsageIndex ?: 0 val result = database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() From 3355446fb8734b256129e4cbde4a3f7ba1725aa5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 28 Aug 2025 11:43:06 +0200 Subject: [PATCH 08/71] Multi accounts - handle login link when there is already an account. --- .../io/element/android/appnav/RootFlowNode.kt | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 291b2fbfafd..d2b1fbe0f04 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -299,19 +299,25 @@ class RootFlowNode( } private suspend fun onLoginLink(params: LoginParams) { - // Is there a session already? - val latestSessionId = authenticationService.getLatestSessionId() - if (latestSessionId == null) { - // No session, open login - if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { - switchToNotLoggedInFlow(params) + if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { + // Is there a session already? + val sessions = sessionStore.getAllSessions() + if (sessions.isNotEmpty()) { + val loginHintMatrixId = params.loginHint?.removePrefix("mxid:") + val existingAccount = sessions.find { it.userId == loginHintMatrixId } + if (existingAccount != null) { + // We have an existing account matching the login hint, ensure this is the current session + sessionStore.setLatestSession(existingAccount.userId) + } else { + val latestSessionId = sessions.maxBy { it.lastUsageIndex }.userId + attachSession(SessionId(latestSessionId)) + backstack.push(NavTarget.NotLoggedInFlow(params)) + } } else { - Timber.w("Login link ignored, we are not allowed to connect to the homeserver") - switchToNotLoggedInFlow(null) + switchToNotLoggedInFlow(params) } } else { - // Just ignore the login link if we already have a session - Timber.w("Login link ignored, we already have a session") + Timber.w("Login link ignored, we are not allowed to connect to the homeserver") } } @@ -322,6 +328,7 @@ class RootFlowNode( // No session, open login switchToNotLoggedInFlow(null) } else { + // TODO Multi-account: show a screen to select an account attachSession(latestSessionId) .attachIncomingShare(intent) } From 00d06f44b257f41a64e8f03fa3d039bb9b119ef5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 28 Aug 2025 14:10:04 +0200 Subject: [PATCH 09/71] Multi accounts - handle click on push history for not current account. --- .../android/appnav/LoggedInFlowNode.kt | 7 +---- .../preferences/api/PreferencesEntryPoint.kt | 3 +- .../preferences/impl/PreferencesFlowNode.kt | 4 +-- .../troubleshoot/api/PushHistoryEntryPoint.kt | 3 +- .../impl/history/PushHistoryEvents.kt | 5 ++++ .../impl/history/PushHistoryNode.kt | 12 ++++---- .../impl/history/PushHistoryPresenter.kt | 28 +++++++++++++++++++ .../impl/history/PushHistoryState.kt | 1 + .../impl/history/PushHistoryStateProvider.kt | 2 ++ .../impl/history/PushHistoryView.kt | 17 +++++------ 10 files changed, 56 insertions(+), 26 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 0e3a9cca742..91173ab360a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -75,7 +75,6 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -412,11 +411,7 @@ class LoggedInFlowNode( backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings)) } - override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) { - // We do not check the sessionId, but it will have to be done at some point (multi account) - if (sessionId != matrixClient.sessionId) { - Timber.e("SessionId mismatch, expected ${matrixClient.sessionId} but got $sessionId") - } + override fun navigateTo(roomId: RoomId, eventId: EventId) { backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId))) } } diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 4520c4b69fd..c0affde2df5 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -15,7 +15,6 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize interface PreferencesEntryPoint : FeatureEntryPoint { @@ -45,6 +44,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint { fun onOpenBugReport() fun onSecureBackupClick() fun onOpenRoomNotificationSettings(roomId: RoomId) - fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) + fun navigateTo(roomId: RoomId, eventId: EventId) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 65644c30f4b..5f25dc17340 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -226,8 +226,8 @@ class PreferencesFlowNode( } } - override fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) { - plugins().forEach { it.navigateTo(sessionId, roomId, eventId) } + override fun navigateTo(roomId: RoomId, eventId: EventId) { + plugins().forEach { it.navigateTo(roomId, eventId) } } }) .build() diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt index 088fb387da0..0eab9b8e5a0 100644 --- a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt @@ -13,7 +13,6 @@ import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId interface PushHistoryEntryPoint : FeatureEntryPoint { fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder @@ -25,6 +24,6 @@ interface PushHistoryEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onDone() - fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) + fun navigateTo(roomId: RoomId, eventId: EventId) } } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt index c18a480899d..7fa47160df9 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt @@ -7,8 +7,13 @@ package io.element.android.libraries.troubleshoot.impl.history +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + sealed interface PushHistoryEvents { data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents + data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId): PushHistoryEvents data object ClearDialog : PushHistoryEvents } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt index 80b938898fe..815f1feaf6e 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt @@ -20,7 +20,6 @@ import io.element.android.annotations.ContributesNode import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint import io.element.android.services.analytics.api.ScreenTracker @@ -29,21 +28,23 @@ import io.element.android.services.analytics.api.ScreenTracker class PushHistoryNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: PushHistoryPresenter, + presenterFactory: PushHistoryPresenter.Factory, private val screenTracker: ScreenTracker, -) : Node(buildContext, plugins = plugins) { +) : Node(buildContext, plugins = plugins), PushHistoryNavigator { private fun onDone() { plugins().forEach { it.onDone() } } - private fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) { + override fun navigateTo(roomId: RoomId, eventId: EventId) { plugins().forEach { - it.onItemClick(sessionId, roomId, eventId) + it.navigateTo(roomId, eventId) } } + private val presenter = presenterFactory.create(this) + @Composable override fun View(modifier: Modifier) { screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot) @@ -51,7 +52,6 @@ class PushHistoryNode( PushHistoryView( state = state, onBackClick = ::onDone, - onItemClick = ::onItemClick, modifier = modifier, ) } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt index 77c38435c33..d9daa331c93 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt @@ -14,18 +14,36 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.push.api.PushService import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +interface PushHistoryNavigator { + fun navigateTo(roomId: RoomId, eventId: EventId) +} + @Inject class PushHistoryPresenter( + @Assisted private val pushHistoryNavigator: PushHistoryNavigator, private val pushService: PushService, + matrixClient: MatrixClient, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(pushHistoryNavigator: PushHistoryNavigator): PushHistoryPresenter + } + + private val sessionId = matrixClient.sessionId + @Composable override fun present(): PushHistoryState { val coroutineScope = rememberCoroutineScope() @@ -41,6 +59,7 @@ class PushHistoryPresenter( } }.collectAsState(emptyList()) var resetAction: AsyncAction by remember { mutableStateOf(AsyncAction.Uninitialized) } + var showNotSameAccountError by remember { mutableStateOf(false) } fun handleEvents(event: PushHistoryEvents) { when (event) { @@ -60,6 +79,14 @@ class PushHistoryPresenter( } PushHistoryEvents.ClearDialog -> { resetAction = AsyncAction.Uninitialized + showNotSameAccountError = false + } + is PushHistoryEvents.NavigateTo -> { + if (event.sessionId != sessionId) { + showNotSameAccountError = true + } else { + pushHistoryNavigator.navigateTo(event.roomId, event.eventId) + } } } } @@ -69,6 +96,7 @@ class PushHistoryPresenter( pushHistoryItems = pushHistory.toImmutableList(), showOnlyErrors = showOnlyErrors, resetAction = resetAction, + showNotSameAccountError = showNotSameAccountError, eventSink = ::handleEvents ) } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt index fda9c6e479c..b4b6d7f75e5 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt @@ -16,5 +16,6 @@ data class PushHistoryState( val pushHistoryItems: ImmutableList, val showOnlyErrors: Boolean, val resetAction: AsyncAction, + val showNotSameAccountError: Boolean, val eventSink: (PushHistoryEvents) -> Unit, ) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt index da37700a931..6b0a1c45bfa 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt @@ -48,12 +48,14 @@ fun aPushHistoryState( pushHistoryItems: List = emptyList(), showOnlyErrors: Boolean = false, resetAction: AsyncAction = AsyncAction.Uninitialized, + showNotSameAccountError: Boolean = false, eventSink: (PushHistoryEvents) -> Unit = {}, ) = PushHistoryState( pushCounter = pushCounter, pushHistoryItems = pushHistoryItems.toImmutableList(), showOnlyErrors = showOnlyErrors, resetAction = resetAction, + showNotSameAccountError = showNotSameAccountError, eventSink = eventSink, ) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt index 2cd2e6dc202..3193716d34a 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt @@ -37,6 +37,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -48,9 +49,6 @@ import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.troubleshoot.impl.R import io.element.android.libraries.ui.strings.CommonStrings @@ -60,7 +58,6 @@ import io.element.android.libraries.ui.strings.CommonStrings fun PushHistoryView( state: PushHistoryState, onBackClick: () -> Unit, - onItemClick: (SessionId, RoomId, EventId) -> Unit, modifier: Modifier = Modifier, ) { var showMenu by remember { mutableStateOf(false) } @@ -123,7 +120,6 @@ fun PushHistoryView( .padding(padding) .consumeWindowInsets(padding), state = state, - onItemClick = onItemClick, ) } @@ -142,12 +138,18 @@ fun PushHistoryView( }, onErrorDismiss = {}, ) + + if (state.showNotSameAccountError) { + ErrorDialog( + content = "Please switch account first to navigate to the event.", + onSubmit = { state.eventSink(PushHistoryEvents.ClearDialog) } + ) + } } @Composable private fun PushHistoryContent( state: PushHistoryState, - onItemClick: (SessionId, RoomId, EventId) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -173,7 +175,7 @@ private fun PushHistoryContent( val roomId = pushHistory.roomId val eventId = pushHistory.eventId if (sessionId != null && roomId != null && eventId != null) { - onItemClick(sessionId, roomId, eventId) + state.eventSink(PushHistoryEvents.NavigateTo(sessionId, roomId, eventId)) } } ) @@ -271,6 +273,5 @@ internal fun PushHistoryViewPreview( PushHistoryView( state = state, onBackClick = {}, - onItemClick = { _, _, _ -> }, ) } From 9fa25c5906412360bc6d571c77ad6d2667ed2d8c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 28 Aug 2025 14:27:01 +0200 Subject: [PATCH 10/71] Multi accounts - improve layout and add preview. --- .../impl/root/PreferencesRootStateProvider.kt | 8 +++-- .../impl/root/PreferencesRootView.kt | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 437716a4c32..604cb10c4d3 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -11,18 +11,20 @@ import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList fun aPreferencesRootState( - myUser: MatrixUser, + myUser: MatrixUser = aMatrixUser(), + otherSessions: List = emptyList(), eventSink: (PreferencesRootEvents) -> Unit = { _ -> }, ) = PreferencesRootState( myUser = myUser, version = "Version 1.1 (1)", deviceId = DeviceId("ILAKNDNASDLK"), isMultiAccountEnabled = true, - otherSessions = persistentListOf(), + otherSessions = otherSessions.toPersistentList(), showSecureBackup = true, showSecureBackupBadge = true, accountManagementUrl = "aUrl", diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 608d2652451..313fa1c86c5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -8,6 +8,7 @@ package io.element.android.features.preferences.impl.root import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -26,9 +27,11 @@ import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem @@ -40,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserProvider import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.ui.strings.CommonStrings @Composable @@ -123,10 +127,14 @@ fun PreferencesRootView( } @Composable -fun ColumnScope.MultiAccountSection( +private fun ColumnScope.MultiAccountSection( state: PreferencesRootState, onAddAccountClick: () -> Unit, ) { + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) state.otherSessions.forEach { matrixUser -> MatrixUserRow( modifier = Modifier.clickable { @@ -135,6 +143,7 @@ fun ColumnScope.MultiAccountSection( matrixUser = matrixUser, avatarSize = AvatarSize.AccountItem, ) + HorizontalDivider() } ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), @@ -143,7 +152,10 @@ fun ColumnScope.MultiAccountSection( }, onClick = onAddAccountClick, ) - HorizontalDivider() + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) } @Composable @@ -334,3 +346,17 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onDeactivateClick = {}, ) } + +@PreviewsDayNight +@Composable +internal fun MultiAccountSectionPreview() = ElementPreview { + Column { + MultiAccountSection( + state = aPreferencesRootState( + otherSessions = aMatrixUserList(), + ), + onAddAccountClick = {}, + ) + } +} + From d2130bca96a365d2dd2b8f23b8adeeca0abbed39 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 28 Aug 2025 15:57:15 +0200 Subject: [PATCH 11/71] Add accountselect modules --- libraries/accountselect/api/build.gradle.kts | 18 ++++ .../api/AccountSelectEntryPoint.kt | 28 ++++++ libraries/accountselect/impl/build.gradle.kts | 39 +++++++++ .../accountselect/impl/AccountSelectEvents.kt | 10 +++ .../accountselect/impl/AccountSelectNode.kt | 48 ++++++++++ .../impl/AccountSelectPresenter.kt | 46 ++++++++++ .../accountselect/impl/AccountSelectState.kt | 16 ++++ .../impl/AccountSelectStateProvider.kt | 29 +++++++ .../accountselect/impl/AccountSelectView.kt | 87 +++++++++++++++++++ .../impl/DefaultAccountSelectEntryPoint.kt | 35 ++++++++ .../impl/AccountSelectPresenterTest.kt | 42 +++++++++ .../kotlin/extension/DependencyHandleScope.kt | 1 + 12 files changed, 399 insertions(+) create mode 100644 libraries/accountselect/api/build.gradle.kts create mode 100644 libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt create mode 100644 libraries/accountselect/impl/build.gradle.kts create mode 100644 libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectEvents.kt create mode 100644 libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt create mode 100644 libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt create mode 100644 libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt create mode 100644 libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt create mode 100644 libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt create mode 100644 libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt create mode 100644 libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt diff --git a/libraries/accountselect/api/build.gradle.kts b/libraries/accountselect/api/build.gradle.kts new file mode 100644 index 00000000000..7e0ce303f9a --- /dev/null +++ b/libraries/accountselect/api/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.accountselect.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt b/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt new file mode 100644 index 00000000000..112293eb6af --- /dev/null +++ b/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * 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.libraries.accountselect.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.SessionId + +interface AccountSelectEntryPoint : FeatureEntryPoint { + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onAccountSelected(sessionId: SessionId) + fun onCancel() + } +} diff --git a/libraries/accountselect/impl/build.gradle.kts b/libraries/accountselect/impl/build.gradle.kts new file mode 100644 index 00000000000..1d2e4dbc2d3 --- /dev/null +++ b/libraries/accountselect/impl/build.gradle.kts @@ -0,0 +1,39 @@ +import extension.setupAnvil + +/* + * 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. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.accountselect.impl" +} + +setupAnvil() + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.sessionStorage.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(projects.libraries.accountselect.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.implMemory) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectEvents.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectEvents.kt new file mode 100644 index 00000000000..87ed2d2f795 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectEvents.kt @@ -0,0 +1,10 @@ +/* + * 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.libraries.accountselect.impl + +sealed interface AccountSelectEvents diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt new file mode 100644 index 00000000000..e64476c816c --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt @@ -0,0 +1,48 @@ +/* + * 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.libraries.accountselect.impl + +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 dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.SessionId + +@ContributesNode(AppScope::class) +class AccountSelectNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AccountSelectPresenter, +) : Node(buildContext, plugins = plugins) { + private val callbacks = plugins.filterIsInstance() + + private fun onDismiss() { + callbacks.forEach { it.onCancel() } + } + + private fun onAccountSelected(sessionId: SessionId) { + callbacks.forEach { it.onAccountSelected(sessionId) } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AccountSelectView( + state = state, + onDismiss = ::onDismiss, + onAccountSelected = ::onAccountSelected, + modifier = modifier, + ) + } +} diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt new file mode 100644 index 00000000000..24047341d98 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -0,0 +1,46 @@ +/* + * 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.libraries.accountselect.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import javax.inject.Inject + +class AccountSelectPresenter @Inject constructor( + private val sessionStore: SessionStore, +) : Presenter { + @Composable + override fun present(): AccountSelectState { + val accounts by produceState(persistentListOf()) { + // Do not use sessionStore.sessionsFlow() to not make it change when an account is selected. + value = sessionStore.getAllSessions() + .map { + MatrixUser( + userId = UserId(it.userId), + displayName = it.userDisplayName, + avatarUrl = it.userAvatarUrl, + ) + } + .toPersistentList() + } + + fun handleEvents(event: AccountSelectEvents) {} + + return AccountSelectState( + accounts = accounts, + eventSink = ::handleEvents + ) + } +} diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt new file mode 100644 index 00000000000..eb8c5c85767 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt @@ -0,0 +1,16 @@ +/* + * 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.libraries.accountselect.impl + +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class AccountSelectState( + val accounts: ImmutableList, + val eventSink: (AccountSelectEvents) -> Unit +) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt new file mode 100644 index 00000000000..c7007fea654 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt @@ -0,0 +1,29 @@ +/* + * 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.libraries.accountselect.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import kotlinx.collections.immutable.toPersistentList + +open class AccountSelectStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAccountSelectState(), + anAccountSelectState(accounts = aMatrixUserList()), + ) +} + +private fun anAccountSelectState( + accounts: List = listOf(), + eventSink: (AccountSelectEvents) -> Unit = {}, +) = AccountSelectState( + accounts = accounts.toPersistentList(), + eventSink = eventSink, +) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt new file mode 100644 index 00000000000..588ffd6932a --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt @@ -0,0 +1,87 @@ +/* + * 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.libraries.accountselect.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.ui.components.MatrixUserRow + +@Suppress("MultipleEmitters") // False positive +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSelectView( + state: AccountSelectState, + onAccountSelected: (SessionId) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = { onDismiss() }) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + // TODO i18n + titleStr = "Select account", + navigationIcon = { + BackButton(onClick = { onDismiss() }) + }, + ) + } + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + LazyColumn { + items(state.accounts, key = { it.userId }) { matrixUser -> + Column { + MatrixUserRow( + modifier = Modifier + .fillMaxWidth() + .clickable { + onAccountSelected(matrixUser.userId) + } + .padding(vertical = 8.dp), + matrixUser = matrixUser, + ) + HorizontalDivider() + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun AccountSelectViewPreview(@PreviewParameter(AccountSelectStateProvider::class) state: AccountSelectState) = ElementPreview { + AccountSelectView( + state = state, + onAccountSelected = {}, + onDismiss = {}, + ) +} diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt new file mode 100644 index 00000000000..908c86c0846 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt @@ -0,0 +1,35 @@ +/* + * 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.libraries.accountselect.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultAccountSelectEntryPoint @Inject constructor() : AccountSelectEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): AccountSelectEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : AccountSelectEntryPoint.NodeBuilder { + override fun callback(callback: AccountSelectEntryPoint.Callback): AccountSelectEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt new file mode 100644 index 00000000000..11756bd9109 --- /dev/null +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -0,0 +1,42 @@ +/* + * 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.libraries.accountselect.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AccountSelectPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createAccountSelectPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.accounts).isEmpty() + } + } + + private fun TestScope.createAccountSelectPresenter( + sessionStore: SessionStore = InMemorySessionStore(), + ) = AccountSelectPresenter( + sessionStore = sessionStore, + ) +} diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 440f9d338b8..2e234a649b6 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -81,6 +81,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:usersearch:impl")) implementation(project(":libraries:textcomposer:impl")) + implementation(project(":libraries:accountselect:impl")) implementation(project(":libraries:roomselect:impl")) implementation(project(":libraries:cryptography:impl")) implementation(project(":libraries:voiceplayer:impl")) From b545f1efcb55fddca25268abd472869a486e7c2e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 28 Aug 2025 17:49:34 +0200 Subject: [PATCH 12/71] Multi accounts - incoming share with account selection --- appnav/build.gradle.kts | 1 + .../io/element/android/appnav/RootFlowNode.kt | 49 +++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 7c29626d75e..a8e2802eda6 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { allFeaturesApi(project) implementation(projects.libraries.core) + implementation(projects.libraries.accountselect.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.deeplink.api) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index d2b1fbe0f04..12c4bddce87 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -40,6 +40,7 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.viewfolder.api.ViewFolderEntryPoint +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.createNode @@ -58,6 +59,7 @@ import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -74,6 +76,7 @@ class RootFlowNode( private val bugReportEntryPoint: BugReportEntryPoint, private val viewFolderEntryPoint: ViewFolderEntryPoint, private val signedOutEntryPoint: SignedOutEntryPoint, + private val accountSelectEntryPoint: AccountSelectEntryPoint, private val intentResolver: IntentResolver, private val oidcActionFlow: OidcActionFlow, private val bugReporter: BugReporter, @@ -184,6 +187,12 @@ class RootFlowNode( @Parcelize data object SplashScreen : NavTarget + @Parcelize + data class AccountSelect( + val currentSessionId: SessionId, + val intent: Intent, + ) : NavTarget + @Parcelize data class NotLoggedInFlow( val params: LoginParams? @@ -278,6 +287,29 @@ class RootFlowNode( .callback(callback) .build() } + is NavTarget.AccountSelect -> { + val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback { + override fun onAccountSelected(sessionId: SessionId) { + lifecycleScope.launch { + if (sessionId == navTarget.currentSessionId) { + // Ensure that the account selection Node is removed from the backstack + // Do not pop when the account is changed to avoid a UI flicker. + backstack.pop() + } + attachSession(sessionId) + .attachIncomingShare(navTarget.intent) + } + } + + override fun onCancel() { + backstack.pop() + } + } + accountSelectEntryPoint + .nodeBuilder(this, buildContext) + .callback(callback) + .build() + } } } @@ -328,9 +360,20 @@ class RootFlowNode( // No session, open login switchToNotLoggedInFlow(null) } else { - // TODO Multi-account: show a screen to select an account - attachSession(latestSessionId) - .attachIncomingShare(intent) + // wait for the current session to be restored + val loggedInFlowNode = attachSession(latestSessionId) + if (sessionStore.getAllSessions().size > 1) { + // Several accounts, let the user choose which one to use + backstack.push( + NavTarget.AccountSelect( + currentSessionId = latestSessionId, + intent = intent, + ) + ) + } else { + // Only one account, directly attach the incoming share node. + loggedInFlowNode.attachIncomingShare(intent) + } } } From 77f0aec65bcd3e531af1fba0c21f188d454f7b87 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 28 Aug 2025 19:00:31 +0200 Subject: [PATCH 13/71] Multi accounts - check the feature flag before allowing login using login link. --- appnav/build.gradle.kts | 1 + .../io/element/android/appnav/RootFlowNode.kt | 23 ++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index a8e2802eda6..add9e6e5e45 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.deeplink.api) + implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.oidc.api) implementation(projects.libraries.preferences.api) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 12c4bddce87..1043dd4e80a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -48,6 +48,8 @@ import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias @@ -81,6 +83,7 @@ class RootFlowNode( private val oidcActionFlow: OidcActionFlow, private val bugReporter: BugReporter, private val sessionStore: SessionStore, + private val featureFlagService: FeatureFlagService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.SplashScreen, @@ -335,15 +338,19 @@ class RootFlowNode( // Is there a session already? val sessions = sessionStore.getAllSessions() if (sessions.isNotEmpty()) { - val loginHintMatrixId = params.loginHint?.removePrefix("mxid:") - val existingAccount = sessions.find { it.userId == loginHintMatrixId } - if (existingAccount != null) { - // We have an existing account matching the login hint, ensure this is the current session - sessionStore.setLatestSession(existingAccount.userId) + if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) { + val loginHintMatrixId = params.loginHint?.removePrefix("mxid:") + val existingAccount = sessions.find { it.userId == loginHintMatrixId } + if (existingAccount != null) { + // We have an existing account matching the login hint, ensure this is the current session + sessionStore.setLatestSession(existingAccount.userId) + } else { + val latestSessionId = sessions.maxBy { it.lastUsageIndex }.userId + attachSession(SessionId(latestSessionId)) + backstack.push(NavTarget.NotLoggedInFlow(params)) + } } else { - val latestSessionId = sessions.maxBy { it.lastUsageIndex }.userId - attachSession(SessionId(latestSessionId)) - backstack.push(NavTarget.NotLoggedInFlow(params)) + Timber.w("Login link ignored, multi account is disabled") } } else { switchToNotLoggedInFlow(params) From 4dc81ed7845c77e463035f787ad93ea5a8218df9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 29 Aug 2025 12:07:50 +0200 Subject: [PATCH 14/71] Multi accounts - swipe on account icon --- .../android/features/home/impl/HomeEvents.kt | 3 + .../features/home/impl/HomePresenter.kt | 61 +++++++++- .../android/features/home/impl/HomeState.kt | 7 +- .../features/home/impl/HomeStateProvider.kt | 4 +- .../android/features/home/impl/HomeView.kt | 5 +- .../home/impl/components/RoomListTopBar.kt | 111 +++++++++++++++--- 6 files changed, 171 insertions(+), 20 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt index 4632e40d5a9..bc0f821845a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt @@ -7,6 +7,9 @@ package io.element.android.features.home.impl +import io.element.android.libraries.matrix.api.core.SessionId + sealed interface HomeEvents { data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents + data class SwitchToAccount(val sessionId: SessionId) : HomeEvents } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index ef51cd7ada7..7449463186d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject @@ -28,8 +29,15 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch @Inject class HomePresenter( @@ -46,7 +54,13 @@ class HomePresenter( ) : Presenter { @Composable override fun present(): HomeState { + val coroutineState = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() + val matrixUserAndNeighbors by remember { + sessionStore.sessionsFlow().map { list -> + list.takeCurrentUserWithNeighbors(matrixUser).toPersistentList() + } + }.collectAsState(initial = persistentListOf(matrixUser)) val isOnline by syncService.isOnline.collectAsState() val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) val roomListState = roomListPresenter.present() @@ -82,12 +96,15 @@ class HomePresenter( is HomeEvents.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } + is HomeEvents.SwitchToAccount -> coroutineState.launch { + sessionStore.setLatestSession(event.sessionId.value) + } } } val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() return HomeState( - matrixUser = matrixUser, + matrixUserAndNeighbors = matrixUserAndNeighbors, showAvatarIndicator = showAvatarIndicator, hasNetworkConnection = isOnline, currentHomeNavigationBarItem = currentHomeNavigationBarItem, @@ -101,3 +118,45 @@ class HomePresenter( ) } } + +private fun List.takeCurrentUserWithNeighbors(matrixUser: MatrixUser): List { + // Sort by userId to always have the same order (not depending on last account usage) + return sortedBy { it.userId } + .map { + if (it.userId == matrixUser.userId.value) { + // Always use the freshest profile for the current user + matrixUser + } else { + // Use the data from the DB + MatrixUser( + userId = UserId(it.userId), + displayName = it.userDisplayName, + avatarUrl = it.userAvatarUrl, + ) + } + } + .let { sessionList -> + // If the list has one item, there is no other session, return the list + when (sessionList.size) { + // Can happen when the user signs out (?) + 0 -> listOf(matrixUser) + 1 -> sessionList + else -> { + // Create a list with extra item at the start and end if necessary to have the current user in the middle + // If the list is [A, B, C, D] and the current user is A we want to return [D, A, B] + // If the current user is B, we want to return [A, B, C] + // If the current user is C, we want to return [B, C, D] + // If the current user is D, we want to return [C, D, A] + val currentUserIndex = sessionList.indexOfFirst { it.userId == matrixUser.userId } + when (currentUserIndex) { + // This can happen when the user signs out. + // In this case, just return a singleton list with the current user. + -1 -> listOf(matrixUser) + 0 -> listOf(sessionList.last()) + sessionList.take(2) + sessionList.lastIndex -> sessionList.takeLast(2) + sessionList.first() + else -> sessionList.slice(currentUserIndex - 1..currentUserIndex + 1) + } + } + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index d8668e32007..ffe82de4f85 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -13,10 +13,15 @@ import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList @Immutable data class HomeState( - val matrixUser: MatrixUser, + /** + * The current user of this session, in case of multiple accounts, will contains 3 items, with the + * current user in the middle. + */ + val matrixUserAndNeighbors: ImmutableList, val showAvatarIndicator: Boolean, val hasNetworkConnection: Boolean, val currentHomeNavigationBarItem: HomeNavigationBarItem, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt index 1f78053c0a1..95904adc071 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt @@ -21,6 +21,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toPersistentList open class HomeStateProvider : PreviewParameterProvider { override val values: Sequence @@ -48,6 +49,7 @@ open class HomeStateProvider : PreviewParameterProvider { internal fun aHomeState( matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), + matrixUserAndNeighbors: List = listOf(matrixUser), showAvatarIndicator: Boolean = false, hasNetworkConnection: Boolean = true, snackbarMessage: SnackbarMessage? = null, @@ -59,7 +61,7 @@ internal fun aHomeState( directLogoutState: DirectLogoutState = aDirectLogoutState(), eventSink: (HomeEvents) -> Unit = {} ) = HomeState( - matrixUser = matrixUser, + matrixUserAndNeighbors = matrixUserAndNeighbors.toPersistentList(), showAvatarIndicator = showAvatarIndicator, hasNetworkConnection = hasNetworkConnection, snackbarMessage = snackbarMessage, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 9217efcfe5d..80b12592953 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -171,12 +171,15 @@ private fun HomeScaffold( topBar = { RoomListTopBar( title = stringResource(state.currentHomeNavigationBarItem.labelRes), - matrixUser = state.matrixUser, + matrixUserAndNeighbors = state.matrixUserAndNeighbors, showAvatarIndicator = state.showAvatarIndicator, areSearchResultsDisplayed = roomListState.searchState.isSearchActive, onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) }, onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, + onAccountSwitch = { + state.eventSink(HomeEvents.SwitchToAccount(it)) + }, scrollBehavior = scrollBehavior, displayMenuItems = state.displayActions, displayFilters = roomListState.displayFilters && state.currentHomeNavigationBarItem == HomeNavigationBarItem.Chats, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt index f1f06afe6d2..1d51e745b42 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt @@ -11,19 +11,25 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState 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 androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -41,7 +47,6 @@ import io.element.android.features.home.impl.filters.RoomListFiltersView import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom import io.element.android.libraries.designsystem.components.avatar.Avatar -import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient @@ -57,23 +62,29 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList @OptIn(ExperimentalMaterial3Api::class) @Composable fun RoomListTopBar( title: String, - matrixUser: MatrixUser, + matrixUserAndNeighbors: ImmutableList, showAvatarIndicator: Boolean, areSearchResultsDisplayed: Boolean, onToggleSearch: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, onOpenSettings: () -> Unit, + onAccountSwitch: (SessionId) -> Unit, scrollBehavior: TopAppBarScrollBehavior, displayMenuItems: Boolean, displayFilters: Boolean, @@ -83,10 +94,11 @@ fun RoomListTopBar( ) { DefaultRoomListTopBar( title = title, - matrixUser = matrixUser, + matrixUserAndNeighbors = matrixUserAndNeighbors, showAvatarIndicator = showAvatarIndicator, areSearchResultsDisplayed = areSearchResultsDisplayed, onOpenSettings = onOpenSettings, + onAccountSwitch = onAccountSwitch, onSearchClick = onToggleSearch, onMenuActionClick = onMenuActionClick, scrollBehavior = scrollBehavior, @@ -102,11 +114,12 @@ fun RoomListTopBar( @Composable private fun DefaultRoomListTopBar( title: String, - matrixUser: MatrixUser, + matrixUserAndNeighbors: ImmutableList, showAvatarIndicator: Boolean, areSearchResultsDisplayed: Boolean, scrollBehavior: TopAppBarScrollBehavior, onOpenSettings: () -> Unit, + onAccountSwitch: (SessionId) -> Unit, onSearchClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, displayMenuItems: Boolean, @@ -116,12 +129,6 @@ private fun DefaultRoomListTopBar( modifier: Modifier = Modifier, ) { val collapsedFraction = scrollBehavior.state.collapsedFraction - val avatarData by remember(matrixUser) { - derivedStateOf { - matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) - } - } - Box(modifier = modifier) { val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy( @@ -158,8 +165,9 @@ private fun DefaultRoomListTopBar( }, navigationIcon = { NavigationIcon( - avatarData = avatarData, + matrixUserAndNeighbors = matrixUserAndNeighbors, showAvatarIndicator = showAvatarIndicator, + onAccountSwitch = onAccountSwitch, onClick = onOpenSettings, ) }, @@ -247,19 +255,67 @@ private fun DefaultRoomListTopBar( @Composable private fun NavigationIcon( - avatarData: AvatarData, + matrixUserAndNeighbors: ImmutableList, + showAvatarIndicator: Boolean, + onAccountSwitch: (SessionId) -> Unit, + onClick: () -> Unit, +) { + if (matrixUserAndNeighbors.size == 1) { + AccountIcon( + matrixUser = matrixUserAndNeighbors.single(), + isCurrentAccount = true, + showAvatarIndicator = showAvatarIndicator, + onClick = onClick, + ) + } else { + // Render a vertical pager + val pagerState = rememberPagerState(initialPage = 1) { matrixUserAndNeighbors.size } + // Listen to page changes and switch account if needed + val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch) + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { page -> + latestOnAccountSwitch(SessionId(matrixUserAndNeighbors[page].userId.value)) + } + } + VerticalPager( + state = pagerState, + modifier = Modifier.height(48.dp), + ) { page -> + AccountIcon( + matrixUser = matrixUserAndNeighbors[page], + isCurrentAccount = page == 1, + showAvatarIndicator = page == 1 && showAvatarIndicator, + onClick = if (page == 1) { + onClick + } else { + {} + }, + ) + } + } +} + +@Composable +private fun AccountIcon( + matrixUser: MatrixUser, + isCurrentAccount: Boolean, showAvatarIndicator: Boolean, onClick: () -> Unit, ) { IconButton( - modifier = Modifier.testTag(TestTags.homeScreenSettings), + modifier = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier, onClick = onClick, ) { Box { + val avatarData by remember(matrixUser) { + derivedStateOf { + matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) + } + } Avatar( avatarData = avatarData, avatarType = AvatarType.User, - contentDescription = stringResource(CommonStrings.common_settings), + contentDescription = if (isCurrentAccount) stringResource(CommonStrings.common_settings) else null, ) if (showAvatarIndicator) { RedIndicatorAtom( @@ -276,11 +332,12 @@ private fun NavigationIcon( internal fun DefaultRoomListTopBarPreview() = ElementPreview { DefaultRoomListTopBar( title = stringResource(R.string.screen_roomlist_main_space_title), - matrixUser = MatrixUser(UserId("@id:domain"), "Alice"), + matrixUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), showAvatarIndicator = false, areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), onOpenSettings = {}, + onAccountSwitch = {}, onSearchClick = {}, displayMenuItems = true, displayFilters = true, @@ -296,11 +353,33 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview { internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview { DefaultRoomListTopBar( title = stringResource(R.string.screen_roomlist_main_space_title), - matrixUser = MatrixUser(UserId("@id:domain"), "Alice"), + matrixUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), showAvatarIndicator = true, areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), onOpenSettings = {}, + onAccountSwitch = {}, + onSearchClick = {}, + displayMenuItems = true, + displayFilters = true, + filtersState = aRoomListFiltersState(), + canReportBug = true, + onMenuActionClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@PreviewsDayNight +@Composable +internal fun DefaultRoomListTopBarMultiAccountPreview() = ElementPreview { + DefaultRoomListTopBar( + title = stringResource(R.string.screen_roomlist_main_space_title), + matrixUserAndNeighbors = aMatrixUserList().take(3).toPersistentList(), + showAvatarIndicator = false, + areSearchResultsDisplayed = false, + scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onAccountSwitch = {}, onSearchClick = {}, displayMenuItems = true, displayFilters = true, From a2bac353e424a0c46f03c885d46abd163ef2485d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 29 Aug 2025 14:43:00 +0200 Subject: [PATCH 15/71] Cleanup --- .../login/impl/screens/onboarding/OnBoardingNode.kt | 2 +- .../login/impl/screens/onboarding/OnBoardingView.kt | 10 +++++----- .../features/preferences/impl/PreferencesFlowNode.kt | 1 - 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt index 47d8ce4b63e..b9897084bf3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -97,7 +97,7 @@ class OnBoardingNode( onNeedLoginPassword = ::onLoginPasswordNeeded, onLearnMoreClick = { openLearnMorePage(context) }, onCreateAccountContinue = ::onCreateAccountContinue, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index 27d736c0aba..ef3ad3f85dd 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -60,7 +60,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun OnBoardingView( state: OnBoardingState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, onSignInWithQrCode: () -> Unit, onSignIn: (mustChooseAccountProvider: Boolean) -> Unit, onCreateAccount: () -> Unit, @@ -98,7 +98,7 @@ fun OnBoardingView( modifier = modifier, loginView = loginView, buttons = buttons, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } else { AddFirstAccountScaffold( @@ -140,7 +140,7 @@ private fun AddFirstAccountScaffold( private fun AddOtherAccountScaffold( loginView: @Composable () -> Unit, buttons: @Composable () -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { FlowStepPage( @@ -150,7 +150,7 @@ private fun AddOtherAccountScaffold( iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), buttons = { buttons() }, content = loginView, - onBackClick = onBackPressed, + onBackClick = onBackClick, ) } @@ -312,7 +312,7 @@ internal fun OnBoardingViewPreview( ) = ElementPreview { OnBoardingView( state = state, - onBackPressed = {}, + onBackClick = {}, onSignInWithQrCode = {}, onSignIn = {}, onCreateAccount = {}, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 5f25dc17340..7de6f6c6eb2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -41,7 +41,6 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint From b80ef4b65b5828c1a28f6a7b6ac84539bb2d1ed2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 1 Sep 2025 10:03:01 +0200 Subject: [PATCH 16/71] Multi accounts - fix other implementation of SessionStore --- .../test/InMemorySessionStore.kt | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt index 8228adada57..646816c61be 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -12,62 +12,62 @@ import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map class InMemorySessionStore( - initialList: List = emptyList(), + private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") }, + private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") }, ) : SessionStore { - private val sessionDataListFlow = MutableStateFlow(initialList) + private var sessionDataFlow = MutableStateFlow(null) override fun isLoggedIn(): Flow { - return sessionDataListFlow.map { - if (it.isEmpty()) { + return sessionDataFlow.map { + if (it == null) { LoggedInState.NotLoggedIn } else { - it.first().let { sessionData -> - LoggedInState.LoggedIn( - sessionId = sessionData.userId, - isTokenValid = sessionData.isTokenValid, - ) - } + LoggedInState.LoggedIn( + sessionId = it.userId, + isTokenValid = it.isTokenValid, + ) } } } - override fun sessionsFlow(): Flow> = sessionDataListFlow.asStateFlow() + override fun sessionsFlow(): Flow> { + return sessionDataFlow.map { listOfNotNull(it) } + } - override suspend fun storeData(sessionData: SessionData) { - val currentList = sessionDataListFlow.value.toMutableList() - currentList.removeAll { it.userId == sessionData.userId } - currentList.add(sessionData) - sessionDataListFlow.value = currentList + override suspend fun addSession(sessionData: SessionData) { + sessionDataFlow.value = sessionData } override suspend fun updateData(sessionData: SessionData) { - val currentList = sessionDataListFlow.value.toMutableList() - val index = currentList.indexOfFirst { it.userId == sessionData.userId } - if (index != -1) { - currentList[index] = sessionData - sessionDataListFlow.value = currentList - } + sessionDataFlow.value = sessionData + } + + override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { + updateUserProfileResult(sessionId, displayName, avatarUrl) } override suspend fun getSession(sessionId: String): SessionData? { - return sessionDataListFlow.value.firstOrNull { it.userId == sessionId } + return sessionDataFlow.value.takeIf { it?.userId == sessionId } } override suspend fun getAllSessions(): List { - return sessionDataListFlow.value + return listOfNotNull(sessionDataFlow.value) } override suspend fun getLatestSession(): SessionData? { - return sessionDataListFlow.value.firstOrNull() + return sessionDataFlow.value + } + + override suspend fun setLatestSession(sessionId: String) { + setLatestSessionResult(sessionId) } override suspend fun removeSession(sessionId: String) { - val currentList = sessionDataListFlow.value.toMutableList() - currentList.removeAll { it.userId == sessionId } - sessionDataListFlow.value = currentList + if (sessionDataFlow.value?.userId == sessionId) { + sessionDataFlow.value = null + } } } From 236bfa6408c029652f63050335e8418b861b3e8c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 1 Sep 2025 10:59:52 +0200 Subject: [PATCH 17/71] Multi accounts - fix PreferencesRootPresenterTest --- features/preferences/impl/build.gradle.kts | 2 + .../impl/root/PreferencesRootPresenterTest.kt | 43 +++++++++++++++++++ .../sessionstorage/test/SessionData.kt | 11 ++++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index d6c716ca87b..c3a4e94e811 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -112,6 +112,8 @@ dependencies { testImplementation(projects.features.logout.test) testImplementation(projects.libraries.indicator.test) testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.sessionStorage.implMemory) + testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) testImplementation(projects.tests.testutils) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 42fee711a77..3409c865298 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -16,15 +16,24 @@ import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsP import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.test.FakeIndicatorService import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -61,6 +70,8 @@ class PreferencesRootPresenterTest { ) ) assertThat(initialState.version).isEqualTo("A Version") + assertThat(initialState.isMultiAccountEnabled).isFalse() + assertThat(initialState.otherSessions).isEmpty() val loadedState = awaitItem() assertThat(loadedState.myUser).isEqualTo( MatrixUser( @@ -174,6 +185,34 @@ class PreferencesRootPresenterTest { } } + @Test + fun `present - multiple accounts`() = runTest { + createPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + canDeactivateAccountResult = { true }, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true) + ), + sessionStore = InMemoryMultiSessionsStore().apply { + addSession(aSessionData(sessionId = A_SESSION_ID.value)) + addSession( + aSessionData( + sessionId = A_SESSION_ID_2.value, + userDisplayName = "Bob", + userAvatarUrl = "avatarUrl", + ) + ) + } + ).test { + val state = awaitFirstItem() + assertThat(state.isMultiAccountEnabled).isTrue() + assertThat(state.otherSessions).hasSize(1) + assertThat(state.otherSessions[0]).isEqualTo(MatrixUser(userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl")) + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { skipItems(1) return awaitItem() @@ -185,6 +224,8 @@ class PreferencesRootPresenterTest { showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)), rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) }, indicatorService: IndicatorService = FakeIndicatorService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + sessionStore: SessionStore = InMemorySessionStore(), ) = PreferencesRootPresenter( matrixClient = matrixClient, sessionVerificationService = sessionVerificationService, @@ -195,5 +236,7 @@ class PreferencesRootPresenterTest { directLogoutPresenter = { aDirectLogoutState() }, showDeveloperSettingsProvider = showDeveloperSettingsProvider, rageshakeFeatureAvailability = rageshakeFeatureAvailability, + featureFlagService = featureFlagService, + sessionStore = sessionStore, ) } diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt index afff40b6e12..db84b3780a8 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.sessionstorage.test import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionData +import java.util.Date fun aSessionData( sessionId: String = "@alice:server.org", @@ -18,7 +19,11 @@ fun aSessionData( cachePath: String = "/a/path/to/a/cache", accessToken: String = "anAccessToken", refreshToken: String? = "aRefreshToken", - ): SessionData { + lastUsageIndex: Long = 0, + lastUsageDate: Date = Date(0), + userDisplayName: String? = null, + userAvatarUrl: String? = null, +): SessionData { return SessionData( userId = sessionId, deviceId = deviceId, @@ -33,5 +38,9 @@ fun aSessionData( passphrase = null, sessionPath = sessionPath, cachePath = cachePath, + lastUsageIndex = lastUsageIndex, + lastUsageDate = lastUsageDate, + userDisplayName = userDisplayName, + userAvatarUrl = userAvatarUrl, ) } From 38a0dc9a1e002634382f61a8b48c7ed5219ef2bf Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 1 Sep 2025 11:05:54 +0200 Subject: [PATCH 18/71] Multi accounts - Add test on AccountSelectPresenter --- libraries/accountselect/impl/build.gradle.kts | 1 + .../impl/AccountSelectPresenterTest.kt | 53 ++++++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/libraries/accountselect/impl/build.gradle.kts b/libraries/accountselect/impl/build.gradle.kts index 1d2e4dbc2d3..f3449b244dd 100644 --- a/libraries/accountselect/impl/build.gradle.kts +++ b/libraries/accountselect/impl/build.gradle.kts @@ -35,5 +35,6 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.implMemory) + testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.tests.testutils) } diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt index 11756bd9109..bb322d13cc6 100644 --- a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -7,14 +7,16 @@ package io.element.android.libraries.accountselect.impl -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule -import kotlinx.coroutines.test.TestScope +import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -26,15 +28,50 @@ class AccountSelectPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = createAccountSelectPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.accounts).isEmpty() } } - private fun TestScope.createAccountSelectPresenter( + @Test + fun `present - multiple accounts case`() = runTest { + val presenter = createAccountSelectPresenter( + sessionStore = InMemoryMultiSessionsStore().apply { + addSession(aSessionData(sessionId = A_SESSION_ID.value)) + addSession( + aSessionData( + sessionId = A_SESSION_ID_2.value, + userDisplayName = "Bob", + userAvatarUrl = "avatarUrl", + ) + ) + } + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.accounts).hasSize(2) + val firstAccount = initialState.accounts[0] + assertThat(firstAccount).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = null, + avatarUrl = null, + ) + ) + val secondAccount = initialState.accounts[1] + assertThat(secondAccount).isEqualTo( + MatrixUser( + userId = A_SESSION_ID_2, + displayName = "Bob", + avatarUrl = "avatarUrl", + ) + ) + } + } + + private fun createAccountSelectPresenter( sessionStore: SessionStore = InMemorySessionStore(), ) = AccountSelectPresenter( sessionStore = sessionStore, From 303927b3f1fb3ecdca7e896d89f1905ad53561cb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 1 Sep 2025 12:15:58 +0200 Subject: [PATCH 19/71] Multi accounts - Fix test on HomePresenter - WIP --- features/home/impl/build.gradle.kts | 2 + .../features/home/impl/HomePresenterTest.kt | 58 +++++++++++++++---- .../test/InMemorySessionStore.kt | 7 ++- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 57fefaf4132..7337a2141c8 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -76,6 +76,8 @@ dependencies { testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.sessionStorage.implMemory) + testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.push.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 0d793f10975..6fa6f3d77ec 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -30,10 +30,14 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -49,22 +53,36 @@ class HomePresenterTest { userAvatarUrl = null, ) matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL))) + val updateUserProfileResult = lambdaRecorder { _, _, _ -> } val presenter = createHomePresenter( client = matrixClient, rageshakeFeatureAvailability = { flowOf(false) }, + sessionStore = InMemorySessionStore( + initialSessionData = aSessionData( + sessionId = matrixClient.sessionId.value, + ), + updateUserProfileResult = updateUserProfileResult, + ), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID)) + assertThat(initialState.matrixUserAndNeighbors.first()).isEqualTo( + MatrixUser(A_USER_ID, null, null) + ) assertThat(initialState.canReportBug).isFalse() val withUserState = awaitItem() - assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID) - assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME) - assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL) + assertThat(withUserState.matrixUserAndNeighbors.first()).isEqualTo( + MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL) + ) assertThat(withUserState.showAvatarIndicator).isFalse() assertThat(withUserState.isSpaceFeatureEnabled).isFalse() + updateUserProfileResult.assertions().isCalledOnce().with( + value(matrixClient.sessionId), + value(A_USER_NAME), + value(AN_AVATAR_URL), + ) } } @@ -72,6 +90,9 @@ class HomePresenterTest { fun `present - can report bug`() = runTest { val presenter = createHomePresenter( rageshakeFeatureAvailability = { flowOf(true) }, + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -89,6 +110,9 @@ class HomePresenterTest { featureFlagService = FakeFeatureFlagService( initialState = mapOf(FeatureFlags.Space.key to true), ), + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), ) presenter.test { skipItems(1) @@ -102,6 +126,9 @@ class HomePresenterTest { val indicatorService = FakeIndicatorService() val presenter = createHomePresenter( indicatorService = indicatorService, + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -121,19 +148,28 @@ class HomePresenterTest { userAvatarUrl = null, ) matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION)) - val presenter = createHomePresenter(client = matrixClient) + val presenter = createHomePresenter( + client = matrixClient, + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId)) + assertThat(initialState.matrixUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId)) // No new state is coming } } @Test fun `present - NavigationBar change`() = runTest { - val presenter = createHomePresenter() + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -145,13 +181,14 @@ class HomePresenterTest { } } - private fun TestScope.createHomePresenter( + private fun createHomePresenter( client: MatrixClient = FakeMatrixClient(), syncService: SyncService = FakeSyncService(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) }, indicatorService: IndicatorService = FakeIndicatorService(), - featureFlagService: FeatureFlagService = FakeFeatureFlagService() + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + sessionStore: SessionStore = InMemorySessionStore(), ) = HomePresenter( client = client, syncService = syncService, @@ -162,5 +199,6 @@ class HomePresenterTest { homeSpacesPresenter = { aHomeSpacesState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, featureFlagService = featureFlagService, + sessionStore = sessionStore, ) } diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt index 646816c61be..1646fb2e140 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -15,10 +15,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map class InMemorySessionStore( + initialSessionData: SessionData? = null, private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") }, private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") }, ) : SessionStore { - private var sessionDataFlow = MutableStateFlow(null) + private var sessionDataFlow = MutableStateFlow(initialSessionData) override fun isLoggedIn(): Flow { return sessionDataFlow.map { @@ -46,6 +47,10 @@ class InMemorySessionStore( } override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { + sessionDataFlow.value = sessionDataFlow.value?.copy( + userDisplayName = displayName, + userAvatarUrl = avatarUrl + ) updateUserProfileResult(sessionId, displayName, avatarUrl) } From 3dc268f1b35ef10a1896a01f45b14ba69f183f68 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Sep 2025 15:58:22 +0200 Subject: [PATCH 20/71] Update database to be able to sort accounts by creation date. --- .../android/features/home/impl/HomePresenter.kt | 4 ++-- .../signedout/impl/SignedOutStateProvider.kt | 2 +- .../libraries/matrix/impl/mapper/Session.kt | 5 +++-- .../libraries/sessionstorage/api/SessionData.kt | 4 ++-- libraries/session-storage/impl/build.gradle.kts | 3 +-- .../sessionstorage/impl/DatabaseSessionStore.kt | 12 +++++------- .../sessionstorage/impl/SessionDataMapper.kt | 4 ++-- .../impl/src/main/sqldelight/databases/10.db | Bin 0 -> 12288 bytes .../libraries/matrix/session/SessionData.sq | 7 ++++--- .../impl/src/main/sqldelight/migrations/9.sqm | 5 +++-- .../sessionstorage/test/SessionData.kt | 5 ++--- 11 files changed, 25 insertions(+), 26 deletions(-) create mode 100644 libraries/session-storage/impl/src/main/sqldelight/databases/10.db diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 7449463186d..f8449721bd1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -120,8 +120,8 @@ class HomePresenter( } private fun List.takeCurrentUserWithNeighbors(matrixUser: MatrixUser): List { - // Sort by userId to always have the same order (not depending on last account usage) - return sortedBy { it.userId } + // Sort by position to always have the same order (not depending on last account usage) + return sortedBy { it.position } .map { if (it.userId == matrixUser.userId.value) { // Always use the freshest profile for the current user diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt index b246183127c..0673f691cb5 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt @@ -44,8 +44,8 @@ private fun aSessionData( passphrase = null, sessionPath = "/a/path/to/a/session", cachePath = "/a/path/to/a/cache", + position = 0, lastUsageIndex = 0, - lastUsageDate = Date(), userDisplayName = null, userAvatarUrl = null, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt index 938eb9bf493..2b5cac67ea2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt @@ -34,8 +34,9 @@ internal fun Session.toSessionData( passphrase = passphrase, sessionPath = sessionPaths.fileDirectory.absolutePath, cachePath = sessionPaths.cacheDirectory.absolutePath, + // Note: position and lastUsageIndex will be set by the SessionStore when adding the session + position = 0, lastUsageIndex = 0, - lastUsageDate = Date(), userDisplayName = null, userAvatarUrl = null, ) @@ -59,8 +60,8 @@ internal fun ExternalSession.toSessionData( passphrase = passphrase, sessionPath = sessionPaths.fileDirectory.absolutePath, cachePath = sessionPaths.cacheDirectory.absolutePath, + position = 0, lastUsageIndex = 0, - lastUsageDate = Date(), userDisplayName = null, userAvatarUrl = null, ) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index f82d2645d7c..4dd53ac379e 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -39,10 +39,10 @@ data class SessionData( val sessionPath: String, /** The path to the cache data stored for the session in the filesystem. */ val cachePath: String, + /** The position, to be able to order account */ + val position: Long, /** The index of the last date of session usage. */ val lastUsageIndex: Long, - /** The last date of session usage. */ - val lastUsageDate: Date, /** The optional display name of the user. */ val userDisplayName: String?, /** The optional avatar URL of the user. */ diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 8517f84c3be..4d6c35a8e9a 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -21,7 +21,6 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) - implementation(projects.services.toolbox.api) api(projects.libraries.sessionStorage.api) implementation(libs.sqldelight.driver.android) implementation(libs.sqlcipher) @@ -40,7 +39,7 @@ dependencies { sqldelight { databases { create("SessionDatabase") { - // https://cashapp.github.io/sqldelight/2.0.0/android_sqlite/migrations/ + // https://sqldelight.github.io/sqldelight/2.1.0/android_sqlite/migrations/ // To generate a .db file from your latest schema, run this task // ./gradlew generateDebugSessionDatabaseSchema // Test migration by running diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 78a504d0eb0..83b09901ad4 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -18,13 +18,11 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber -import java.util.Date @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) @@ -32,7 +30,6 @@ import java.util.Date class DatabaseSessionStore( private val database: SessionDatabase, private val dispatchers: CoroutineDispatchers, - private val systemClock: SystemClock, ) : SessionStore { private val sessionDataMutex = Mutex() @@ -58,8 +55,10 @@ class DatabaseSessionStore( database.sessionDataQueries.insertSessionData( sessionData .copy( + // position value does not really matter, so just use lastUsageIndex + 1 to ensure that + // the value is always greater than value of any existing account + position = lastUsageIndex + 1, lastUsageIndex = lastUsageIndex + 1, - lastUsageDate = Date(systemClock.epochMillis()), ) .toDbModel() ) @@ -80,8 +79,8 @@ class DatabaseSessionStore( database.sessionDataQueries.updateSession( sessionData.copy( loginTimestamp = result.loginTimestamp, + position = result.position, lastUsageIndex = result.lastUsageIndex, - lastUsageDate = result.lastUsageDate, userDisplayName = result.userDisplayName, userAvatarUrl = result.userAvatarUrl, ).toDbModel() @@ -122,11 +121,10 @@ class DatabaseSessionStore( return } sessionDataMutex.withLock { - // Update lastUsageDate and lastSessionIndex of the session + // Update lastUsageIndex of the session database.sessionDataQueries.updateSession( result.copy( lastUsageIndex = lastUsageIndex + 1, - lastUsageDate = Date(systemClock.epochMillis()), ).toDbModel() ) } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt index 35a2690a3aa..3b694c01244 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -27,8 +27,8 @@ internal fun SessionData.toDbModel(): DbSessionData { passphrase = passphrase, sessionPath = sessionPath, cachePath = cachePath, + position = position, lastUsageIndex = lastUsageIndex, - lastUsageDate = lastUsageDate.time, userDisplayName = userDisplayName, userAvatarUrl = userAvatarUrl, ) @@ -49,8 +49,8 @@ internal fun DbSessionData.toApiModel(): SessionData { passphrase = passphrase, sessionPath = sessionPath, cachePath = cachePath, + position = position, lastUsageIndex = lastUsageIndex, - lastUsageDate = Date(lastUsageDate), userDisplayName = userDisplayName, userAvatarUrl = userAvatarUrl, ) diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/10.db b/libraries/session-storage/impl/src/main/sqldelight/databases/10.db new file mode 100644 index 0000000000000000000000000000000000000000..fe31cc0fac86b320c3c23d932a82cdbfc8983b4e GIT binary patch literal 12288 zcmeH~&u`N(6vvZ-0BxGK+pbUBg^6ui8AC!75`_j-QA%l3#7>o)c#TCG7u&7c^CZNP ze~JH!BhOB=wy>%a$Ld*18{6;K_v4pHX@9BbUJG;NV()NwFeY5*(odfL?0U|&IhyW2F0z`la5P=OR@Z-{dxPN%) z|9oj!FXe5nwUAkr8Mbg+deaIjVE|#}bv=NE4S2i-I7XVQS`1@Ra_C*#73~kt82(?BSybD^Zz0RGN6mev+ ztx56B8H-%%XNL6^3FhRg!P`ow9zr?Knb$KN@-?9^T<9UwdYCAt*O@I9M(Hg^LlfDB=GZp?u~-;Asq{76wn@7>tA-2iuPJR!e07`$LX01HCECDuzgL zX6+$nhUd~}5mpmDyOkocm`^}ngGo4~OdD)s*5^g^QwUHL80-RP5sj4MnEy6;!m>Wc zQqg-U)XcCDRoDxSW?1n6gJ930EbBR76Bx5}#Ni2)Q78yh$Rpd;cV%@!ZLJ+!YRxH_ zb?3j$GnZmS+o>W=-{vp+P501+SpM1Tko0U|&IhyW2F u0z`la5P?l4uw{kO_y49IFXb#0GG Date: Thu, 4 Sep 2025 16:20:26 +0200 Subject: [PATCH 21/71] Add unit test on takeCurrentUserWithNeighbors --- .../features/home/impl/HomePresenter.kt | 4 +- .../home/impl/AccountNeighborsTest.kt | 212 ++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 features/home/impl/src/test/kotlin/io/element/android/features/home/impl/AccountNeighborsTest.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index f8449721bd1..f7eb6df537b 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -7,6 +7,7 @@ package io.element.android.features.home.impl +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -119,7 +120,8 @@ class HomePresenter( } } -private fun List.takeCurrentUserWithNeighbors(matrixUser: MatrixUser): List { +@VisibleForTesting +internal fun List.takeCurrentUserWithNeighbors(matrixUser: MatrixUser): List { // Sort by position to always have the same order (not depending on last account usage) return sortedBy { it.position } .map { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/AccountNeighborsTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/AccountNeighborsTest.kt new file mode 100644 index 00000000000..13a27d726ec --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/AccountNeighborsTest.kt @@ -0,0 +1,212 @@ +/* + * 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.home.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.test.aSessionData +import org.junit.Test + +class AccountNeighborsTest { + @Test + fun `takeCurrentUserWithNeighbors on empty list returns current user`() { + val matrixUser = aMatrixUser() + val list = listOf() + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result).containsExactly(matrixUser) + } + + @Test + fun `ensure that account are sorted by position`() { + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + position = 3, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + position = 2, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + position = 1, + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID_3, + A_USER_ID_2, + A_USER_ID, + ) + } + + @Test + fun `if current user is not found, return a singleton with current user`() { + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID_2.value, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + ) + } + + @Test + fun `one account, will return a singleton`() { + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + ) + } + + @Test + fun `two accounts, first is current, will return 3 items`() { + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID_2, + A_USER_ID, + A_USER_ID_2, + ) + } + + @Test + fun `two accounts, second is current, will return 3 items`() { + val matrixUser = aMatrixUser(id = A_USER_ID_2.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + A_USER_ID_2, + A_USER_ID, + ) + } + + @Test + fun `three accounts, first is current, will return last current and next`() { + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID_3, + A_USER_ID, + A_USER_ID_2, + ) + } + + @Test + fun `three accounts, second is current, will return first current and last`() { + val matrixUser = aMatrixUser(id = A_USER_ID_2.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + A_USER_ID_2, + A_USER_ID_3, + ) + } + + @Test + fun `three accounts, current is last, will return middle, current and first`() { + val matrixUser = aMatrixUser(id = A_USER_ID_3.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID_2.value, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + ), + aSessionData( + sessionId = A_USER_ID.value, + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + A_USER_ID_2, + A_USER_ID_3, + ) + } + + @Test + fun `one account, will return data from matrix user and not from db`() { + val matrixUser = aMatrixUser( + id = A_USER_ID.value, + displayName = "Bob", + avatarUrl = "avatarUrl", + ) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = "Outdated Bob", + userAvatarUrl = "outdatedAvatarUrl", + ), + ) + val result = list.takeCurrentUserWithNeighbors(matrixUser) + assertThat(result).containsExactly( + MatrixUser( + userId = A_USER_ID, + displayName = "Bob", + avatarUrl = "avatarUrl", + ) + ) + } +} From 3dda8ac66164e67ef19f5caabf49d2e33c1d6adb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Sep 2025 16:41:08 +0200 Subject: [PATCH 22/71] Fix test and improve code. --- .../features/home/impl/HomePresenter.kt | 27 ++++++++++--------- .../features/home/impl/HomePresenterTest.kt | 21 +++++++++++---- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index f7eb6df537b..6a1fed6d654 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -37,7 +37,8 @@ import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @Inject @@ -58,8 +59,19 @@ class HomePresenter( val coroutineState = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() val matrixUserAndNeighbors by remember { - sessionStore.sessionsFlow().map { list -> - list.takeCurrentUserWithNeighbors(matrixUser).toPersistentList() + combine( + client.userProfile.onEach { user -> + // Ensure that the profile is always up to date in our + // session storage when it changes + sessionStore.updateUserProfile( + sessionId = user.userId.value, + displayName = user.displayName, + avatarUrl = user.avatarUrl, + ) + }, + sessionStore.sessionsFlow() + ) { user, sessions -> + sessions.takeCurrentUserWithNeighbors(user).toPersistentList() } }.collectAsState(initial = persistentListOf(matrixUser)) val isOnline by syncService.isOnline.collectAsState() @@ -79,15 +91,6 @@ class HomePresenter( // Force a refresh of the profile client.getUserProfile() } - LaunchedEffect(matrixUser) { - // Ensure that the profile is always up to date in our - // session storage when it changes - sessionStore.updateUserProfile( - sessionId = matrixUser.userId.value, - displayName = matrixUser.displayName, - avatarUrl = matrixUser.avatarUrl, - ) - } // Avatar indicator val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val directLogoutState = logoutPresenter.present() diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 6fa6f3d77ec..bda91e49422 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -60,6 +60,8 @@ class HomePresenterTest { sessionStore = InMemorySessionStore( initialSessionData = aSessionData( sessionId = matrixClient.sessionId.value, + userDisplayName = null, + userAvatarUrl = null, ), updateUserProfileResult = updateUserProfileResult, ), @@ -72,17 +74,26 @@ class HomePresenterTest { MatrixUser(A_USER_ID, null, null) ) assertThat(initialState.canReportBug).isFalse() + skipItems(1) val withUserState = awaitItem() assertThat(withUserState.matrixUserAndNeighbors.first()).isEqualTo( MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL) ) assertThat(withUserState.showAvatarIndicator).isFalse() assertThat(withUserState.isSpaceFeatureEnabled).isFalse() - updateUserProfileResult.assertions().isCalledOnce().with( - value(matrixClient.sessionId), - value(A_USER_NAME), - value(AN_AVATAR_URL), - ) + updateUserProfileResult.assertions().isCalledExactly(2) + .withSequence( + listOf( + value(matrixClient.sessionId.value), + value(null), + value(null), + ), + listOf( + value(matrixClient.sessionId.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + ), + ) } } From 29154cfdc51d045cd26d56e86b2035b7cecb595c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Sep 2025 16:42:32 +0200 Subject: [PATCH 23/71] Add exception --- .../io/element/android/tests/konsist/KonsistPreviewTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index d0408fb61a0..39c418e9848 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -82,6 +82,7 @@ class KonsistPreviewTest { "BackgroundVerticalGradientEnterprisePreview", "BackgroundVerticalGradientPreview", "ColorAliasesPreview", + "DefaultRoomListTopBarMultiAccountPreview", "DefaultRoomListTopBarWithIndicatorPreview", "FocusedEventEnterprisePreview", "FocusedEventPreview", From 0798c3aff607954afb1ce0467a44536ad3d86a88 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Sep 2025 16:59:29 +0200 Subject: [PATCH 24/71] Multi accounts - handle permalink --- .../io/element/android/appnav/RootFlowNode.kt | 78 +++++++++++++------ .../matrix/api/permalink/PermalinkData.kt | 5 +- 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 1043dd4e80a..3e8a0a65092 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -193,7 +193,8 @@ class RootFlowNode( @Parcelize data class AccountSelect( val currentSessionId: SessionId, - val intent: Intent, + val intent: Intent?, + val permalinkData: PermalinkData?, ) : NavTarget @Parcelize @@ -299,8 +300,13 @@ class RootFlowNode( // Do not pop when the account is changed to avoid a UI flicker. backstack.pop() } - attachSession(sessionId) - .attachIncomingShare(navTarget.intent) + attachSession(sessionId).apply { + if (navTarget.intent != null) { + attachIncomingShare(navTarget.intent) + } else if (navTarget.permalinkData != null) { + attachPermalinkData(navTarget.permalinkData) + } + } } } @@ -375,6 +381,7 @@ class RootFlowNode( NavTarget.AccountSelect( currentSessionId = latestSessionId, intent = intent, + permalinkData = null, ) ) } else { @@ -386,25 +393,53 @@ class RootFlowNode( private suspend fun navigateTo(permalinkData: PermalinkData) { Timber.d("Navigating to $permalinkData") - attachSession(null) - .apply { - when (permalinkData) { - is PermalinkData.FallbackLink -> Unit - is PermalinkData.RoomEmailInviteLink -> Unit - is PermalinkData.RoomLink -> { - attachRoom( - roomIdOrAlias = permalinkData.roomIdOrAlias, - trigger = JoinedRoom.Trigger.MobilePermalink, - serverNames = permalinkData.viaParameters, - eventId = permalinkData.eventId, - clearBackstack = true + // Is there a session already? + val latestSessionId = authenticationService.getLatestSessionId() + if (latestSessionId == null) { + // No session, open login + switchToNotLoggedInFlow(null) + } else { + // wait for the current session to be restored + val loggedInFlowNode = attachSession(latestSessionId) + when (permalinkData) { + is PermalinkData.FallbackLink -> Unit + is PermalinkData.RoomEmailInviteLink -> Unit + else -> { + if (sessionStore.getAllSessions().size > 1) { + // Several accounts, let the user choose which one to use + backstack.push( + NavTarget.AccountSelect( + currentSessionId = latestSessionId, + intent = null, + permalinkData = permalinkData, + ) ) - } - is PermalinkData.UserLink -> { - attachUser(permalinkData.userId) + } else { + // Only one account, directly attach the room or the user node. + loggedInFlowNode.attachPermalinkData(permalinkData) } } } + } + } + + private suspend fun LoggedInFlowNode.attachPermalinkData(permalinkData: PermalinkData) { + when (permalinkData) { + is PermalinkData.FallbackLink -> Unit + is PermalinkData.RoomEmailInviteLink -> Unit + is PermalinkData.RoomLink -> { + attachRoom( + roomIdOrAlias = permalinkData.roomIdOrAlias, + trigger = JoinedRoom.Trigger.MobilePermalink, + serverNames = permalinkData.viaParameters, + eventId = permalinkData.eventId, + clearBackstack = true + ) + } + is PermalinkData.UserLink -> { + attachUser(permalinkData.userId) + } + } } private suspend fun navigateTo(deeplinkData: DeeplinkData) { @@ -422,12 +457,11 @@ class RootFlowNode( oidcActionFlow.post(oidcAction) } - // [sessionId] will be null for permalink. - private suspend fun attachSession(sessionId: SessionId?): LoggedInFlowNode { + private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { // Ensure that the session is the latest one - sessionId?.let { sessionStore.setLatestSession(it.value) } + sessionStore.setLatestSession(sessionId.value) return waitForChildAttached { navTarget -> - navTarget is NavTarget.LoggedInFlow && (sessionId == null || navTarget.sessionId == sessionId) + navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId } .attachSession() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkData.kt index 1f9dd8af8d8..1f5f39dee70 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkData.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkData.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.api.permalink import android.net.Uri +import android.os.Parcelable import androidx.compose.runtime.Immutable import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -15,13 +16,15 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.UserId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.parcelize.Parcelize /** * This sealed class represents all the permalink cases. * You don't have to instantiate yourself but should use [PermalinkParser] instead. */ @Immutable -sealed interface PermalinkData { +@Parcelize +sealed interface PermalinkData : Parcelable { data class RoomLink( val roomIdOrAlias: RoomIdOrAlias, val eventId: EventId? = null, From e3a2c6278dbc6cc2bf4db1a881bd760367a29dc0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Sep 2025 17:31:38 +0200 Subject: [PATCH 25/71] Code quality --- .../main/kotlin/io/element/android/appnav/RootFlowNode.kt | 2 +- .../features/preferences/impl/root/PreferencesRootView.kt | 1 - .../features/signedout/impl/SignedOutStateProvider.kt | 1 - .../libraries/accountselect/api/AccountSelectEntryPoint.kt | 2 +- .../libraries/accountselect/impl/AccountSelectNode.kt | 6 +++--- .../libraries/accountselect/impl/AccountSelectView.kt | 6 +++--- .../android/libraries/sessionstorage/api/SessionData.kt | 2 +- .../libraries/sessionstorage/impl/DatabaseSessionStore.kt | 3 ++- .../troubleshoot/impl/history/PushHistoryEvents.kt | 2 +- .../troubleshoot/impl/history/PushHistoryPresenter.kt | 2 +- 10 files changed, 13 insertions(+), 14 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 3e8a0a65092..634948d7e2d 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -293,7 +293,7 @@ class RootFlowNode( } is NavTarget.AccountSelect -> { val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback { - override fun onAccountSelected(sessionId: SessionId) { + override fun onSelectAccount(sessionId: SessionId) { lifecycleScope.launch { if (sessionId == navTarget.currentSessionId) { // Ensure that the account selection Node is removed from the backstack diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 313fa1c86c5..f34f05350d0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -359,4 +359,3 @@ internal fun MultiAccountSectionPreview() = ElementPreview { ) } } - diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt index 0673f691cb5..a7b95a85377 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt @@ -10,7 +10,6 @@ package io.element.android.features.signedout.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionData -import java.util.Date open class SignedOutStateProvider : PreviewParameterProvider { override val values: Sequence diff --git a/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt b/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt index 112293eb6af..72da3491de9 100644 --- a/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt +++ b/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt @@ -22,7 +22,7 @@ interface AccountSelectEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onAccountSelected(sessionId: SessionId) + fun onSelectAccount(sessionId: SessionId) fun onCancel() } } diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt index e64476c816c..88153839fc9 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt @@ -31,8 +31,8 @@ class AccountSelectNode @AssistedInject constructor( callbacks.forEach { it.onCancel() } } - private fun onAccountSelected(sessionId: SessionId) { - callbacks.forEach { it.onAccountSelected(sessionId) } + private fun onSelectAccount(sessionId: SessionId) { + callbacks.forEach { it.onSelectAccount(sessionId) } } @Composable @@ -41,7 +41,7 @@ class AccountSelectNode @AssistedInject constructor( AccountSelectView( state = state, onDismiss = ::onDismiss, - onAccountSelected = ::onAccountSelected, + onSelectAccount = ::onSelectAccount, modifier = modifier, ) } diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt index 588ffd6932a..293169eb441 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt @@ -34,7 +34,7 @@ import io.element.android.libraries.matrix.ui.components.MatrixUserRow @Composable fun AccountSelectView( state: AccountSelectState, - onAccountSelected: (SessionId) -> Unit, + onSelectAccount: (SessionId) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -63,7 +63,7 @@ fun AccountSelectView( modifier = Modifier .fillMaxWidth() .clickable { - onAccountSelected(matrixUser.userId) + onSelectAccount(matrixUser.userId) } .padding(vertical = 8.dp), matrixUser = matrixUser, @@ -81,7 +81,7 @@ fun AccountSelectView( internal fun AccountSelectViewPreview(@PreviewParameter(AccountSelectStateProvider::class) state: AccountSelectState) = ElementPreview { AccountSelectView( state = state, - onAccountSelected = {}, + onSelectAccount = {}, onDismiss = {}, ) } diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index 4dd53ac379e..90b0a0b7c75 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -39,7 +39,7 @@ data class SessionData( val sessionPath: String, /** The path to the cache data stored for the session in the filesystem. */ val cachePath: String, - /** The position, to be able to order account */ + /** The position, to be able to order account. */ val position: Long, /** The index of the last date of session usage. */ val lastUsageIndex: Long, diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 83b09901ad4..a42f807253b 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -133,7 +133,8 @@ class DatabaseSessionStore( private fun getLastUsageIndex(): Long { return database.sessionDataQueries.selectLatest() .executeAsOneOrNull() - ?.lastUsageIndex ?: 0L + ?.lastUsageIndex + ?: 0L } override suspend fun getLatestSession(): SessionData? { diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt index 7fa47160df9..893be607a09 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt @@ -14,6 +14,6 @@ import io.element.android.libraries.matrix.api.core.SessionId sealed interface PushHistoryEvents { data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents - data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId): PushHistoryEvents + data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId) : PushHistoryEvents data object ClearDialog : PushHistoryEvents } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt index d9daa331c93..e2b337dc13f 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt @@ -59,7 +59,7 @@ class PushHistoryPresenter( } }.collectAsState(emptyList()) var resetAction: AsyncAction by remember { mutableStateOf(AsyncAction.Uninitialized) } - var showNotSameAccountError by remember { mutableStateOf(false) } + var showNotSameAccountError by remember { mutableStateOf(false) } fun handleEvents(event: PushHistoryEvents) { when (event) { From 718cd5b8e10eb6b4721cfae557ab4d4c4d5eab58 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Sep 2025 17:47:35 +0200 Subject: [PATCH 26/71] Multi accounts - localization --- .../android/features/login/impl/login/LoginModeView.kt | 3 +-- .../features/login/impl/screens/onboarding/OnBoardingView.kt | 3 +-- .../features/preferences/impl/root/PreferencesRootView.kt | 2 +- .../libraries/accountselect/impl/AccountSelectView.kt | 5 +++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt index 307e82a863c..b1528176911 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt @@ -91,9 +91,8 @@ fun LoginModeView( ) } is AuthenticationException.AccountAlreadyLoggedIn -> { - // TODO i18n ErrorDialog( - content = "You're already logged in on this device as ${error.message}.", + content = stringResource(CommonStrings.error_account_already_logged_in, error.message.orEmpty()), onSubmit = onClearError, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index ef3ad3f85dd..fbc4dc6d099 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -145,8 +145,7 @@ private fun AddOtherAccountScaffold( ) { FlowStepPage( modifier = modifier, - // TODO i18n - title = "Add account", + title = stringResource(CommonStrings.common_add_account), iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), buttons = { buttons() }, content = loginView, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index f34f05350d0..afa9bab0ae5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -148,7 +148,7 @@ private fun ColumnScope.MultiAccountSection( ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), headlineContent = { - Text("Add another account") + Text(stringResource(CommonStrings.common_add_another_account)) }, onClick = onAddAccountClick, ) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt index 293169eb441..b589df23f60 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.components.button.BackButton @@ -28,6 +29,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.ui.strings.CommonStrings @Suppress("MultipleEmitters") // False positive @OptIn(ExperimentalMaterial3Api::class) @@ -43,8 +45,7 @@ fun AccountSelectView( modifier = modifier, topBar = { TopAppBar( - // TODO i18n - titleStr = "Select account", + titleStr = stringResource(CommonStrings.common_select_account), navigationIcon = { BackButton(onClick = { onDismiss() }) }, From 462491c3c73316f4bf010033454086e7df7373f3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Sep 2025 18:37:05 +0200 Subject: [PATCH 27/71] Fix issue after rebase on develop --- features/home/impl/build.gradle.kts | 1 - features/preferences/impl/build.gradle.kts | 1 - libraries/accountselect/impl/build.gradle.kts | 5 ++--- .../libraries/accountselect/impl/AccountSelectNode.kt | 9 +++++---- .../accountselect/impl/AccountSelectPresenter.kt | 5 +++-- .../accountselect/impl/DefaultAccountSelectEntryPoint.kt | 9 +++++---- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 7337a2141c8..be6c0773d9d 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -76,7 +76,6 @@ dependencies { testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) - testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.push.test) testImplementation(projects.services.analytics.test) diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index c3a4e94e811..e84054cdb3f 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -112,7 +112,6 @@ dependencies { testImplementation(projects.features.logout.test) testImplementation(projects.libraries.indicator.test) testImplementation(projects.libraries.pushproviders.test) - testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) diff --git a/libraries/accountselect/impl/build.gradle.kts b/libraries/accountselect/impl/build.gradle.kts index f3449b244dd..88a82e56930 100644 --- a/libraries/accountselect/impl/build.gradle.kts +++ b/libraries/accountselect/impl/build.gradle.kts @@ -1,4 +1,4 @@ -import extension.setupAnvil +import extension.setupDependencyInjection /* * Copyright 2025 New Vector Ltd. @@ -15,7 +15,7 @@ android { namespace = "io.element.android.libraries.accountselect.impl" } -setupAnvil() +setupDependencyInjection() dependencies { implementation(projects.libraries.core) @@ -34,7 +34,6 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.tests.testutils) } diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt index 88153839fc9..61eb1c6e7d2 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt @@ -12,15 +12,16 @@ 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 dagger.assisted.Assisted -import dagger.assisted.AssistedInject +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.Inject import io.element.android.anvilannotations.ContributesNode import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint -import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.core.SessionId @ContributesNode(AppScope::class) -class AccountSelectNode @AssistedInject constructor( +@Inject +class AccountSelectNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val presenter: AccountSelectPresenter, diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt index 24047341d98..0c5fa3d68d3 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -10,15 +10,16 @@ package io.element.android.libraries.accountselect.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState +import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import javax.inject.Inject -class AccountSelectPresenter @Inject constructor( +@Inject +class AccountSelectPresenter( private val sessionStore: SessionStore, ) : Presenter { @Composable diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt index 908c86c0846..baf5ecd5b3f 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt @@ -10,14 +10,15 @@ package io.element.android.libraries.accountselect.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin -import com.squareup.anvil.annotations.ContributesBinding +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.createNode -import io.element.android.libraries.di.AppScope -import javax.inject.Inject @ContributesBinding(AppScope::class) -class DefaultAccountSelectEntryPoint @Inject constructor() : AccountSelectEntryPoint { +@Inject +class DefaultAccountSelectEntryPoint : AccountSelectEntryPoint { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): AccountSelectEntryPoint.NodeBuilder { val plugins = ArrayList() From 5b9e2339122051bb24b02033e2c81f988dfaff30 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 09:55:44 +0200 Subject: [PATCH 28/71] Fix issue after rebase on develop --- .../features/home/impl/HomePresenterTest.kt | 12 +++-- .../impl/root/PreferencesRootPresenterTest.kt | 13 +++-- .../impl/AccountSelectPresenterTest.kt | 13 +++-- .../test/InMemorySessionStore.kt | 51 ++++++++++--------- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index bda91e49422..cd533e00e93 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -58,10 +58,12 @@ class HomePresenterTest { client = matrixClient, rageshakeFeatureAvailability = { flowOf(false) }, sessionStore = InMemorySessionStore( - initialSessionData = aSessionData( - sessionId = matrixClient.sessionId.value, - userDisplayName = null, - userAvatarUrl = null, + initialList = listOf( + aSessionData( + sessionId = matrixClient.sessionId.value, + userDisplayName = null, + userAvatarUrl = null, + ) ), updateUserProfileResult = updateUserProfileResult, ), diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 3409c865298..0f6eec3c6d7 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -31,8 +31,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore -import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule @@ -195,16 +194,16 @@ class PreferencesRootPresenterTest { featureFlagService = FakeFeatureFlagService( initialState = mapOf(FeatureFlags.MultiAccount.key to true) ), - sessionStore = InMemoryMultiSessionsStore().apply { - addSession(aSessionData(sessionId = A_SESSION_ID.value)) - addSession( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), aSessionData( sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", - ) + ), ) - } + ) ).test { val state = awaitFirstItem() assertThat(state.isMultiAccountEnabled).isTrue() diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt index bb322d13cc6..399402342bc 100644 --- a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -12,8 +12,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore -import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test @@ -37,16 +36,16 @@ class AccountSelectPresenterTest { @Test fun `present - multiple accounts case`() = runTest { val presenter = createAccountSelectPresenter( - sessionStore = InMemoryMultiSessionsStore().apply { - addSession(aSessionData(sessionId = A_SESSION_ID.value)) - addSession( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), aSessionData( sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", - ) + ), ) - } + ) ) presenter.test { skipItems(1) diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt index 1646fb2e140..c28f87bb543 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -12,58 +12,63 @@ import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map class InMemorySessionStore( - initialSessionData: SessionData? = null, + initialList: List = emptyList(), private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") }, private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") }, ) : SessionStore { - private var sessionDataFlow = MutableStateFlow(initialSessionData) + private val sessionDataListFlow = MutableStateFlow(initialList) override fun isLoggedIn(): Flow { - return sessionDataFlow.map { - if (it == null) { + return sessionDataListFlow.map { + if (it.isEmpty()) { LoggedInState.NotLoggedIn } else { - LoggedInState.LoggedIn( - sessionId = it.userId, - isTokenValid = it.isTokenValid, - ) + it.first().let { sessionData -> + LoggedInState.LoggedIn( + sessionId = sessionData.userId, + isTokenValid = sessionData.isTokenValid, + ) + } } } } - override fun sessionsFlow(): Flow> { - return sessionDataFlow.map { listOfNotNull(it) } - } + override fun sessionsFlow(): Flow> = sessionDataListFlow.asStateFlow() override suspend fun addSession(sessionData: SessionData) { - sessionDataFlow.value = sessionData + val currentList = sessionDataListFlow.value.toMutableList() + currentList.removeAll { it.userId == sessionData.userId } + currentList.add(sessionData) + sessionDataListFlow.value = currentList } override suspend fun updateData(sessionData: SessionData) { - sessionDataFlow.value = sessionData + val currentList = sessionDataListFlow.value.toMutableList() + val index = currentList.indexOfFirst { it.userId == sessionData.userId } + if (index != -1) { + currentList[index] = sessionData + sessionDataListFlow.value = currentList + } } override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { - sessionDataFlow.value = sessionDataFlow.value?.copy( - userDisplayName = displayName, - userAvatarUrl = avatarUrl - ) updateUserProfileResult(sessionId, displayName, avatarUrl) } override suspend fun getSession(sessionId: String): SessionData? { - return sessionDataFlow.value.takeIf { it?.userId == sessionId } + return sessionDataListFlow.value.firstOrNull { it.userId == sessionId } } override suspend fun getAllSessions(): List { - return listOfNotNull(sessionDataFlow.value) + return sessionDataListFlow.value } override suspend fun getLatestSession(): SessionData? { - return sessionDataFlow.value + return sessionDataListFlow.value.firstOrNull() } override suspend fun setLatestSession(sessionId: String) { @@ -71,8 +76,8 @@ class InMemorySessionStore( } override suspend fun removeSession(sessionId: String) { - if (sessionDataFlow.value?.userId == sessionId) { - sessionDataFlow.value = null - } + val currentList = sessionDataListFlow.value.toMutableList() + currentList.removeAll { it.userId == sessionId } + sessionDataListFlow.value = currentList } } From cabaf3490ccb7df26d217126204f58a142334414 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 11:43:15 +0200 Subject: [PATCH 29/71] Fix tests --- .../appnav/intent/IntentResolverTest.kt | 4 +-- features/login/impl/build.gradle.kts | 2 ++ .../features/login/impl/LoginFlowNode.kt | 2 +- .../features/login/impl/login/LoginHelper.kt | 4 +-- .../ConfirmAccountProviderPresenterTest.kt | 27 +++++++++++++++++-- .../onboarding/OnBoardingPresenterTest.kt | 22 +++++++++++++++ .../screens/onboarding/OnboardingViewTest.kt | 18 +++++++++++++ .../android/libraries/oidc/api/OidcAction.kt | 2 +- .../libraries/oidc/impl/OidcUrlParser.kt | 2 +- .../oidc/impl/DefaultOidcActionFlowTest.kt | 4 +-- .../impl/DefaultOidcIntentResolverTest.kt | 2 +- .../oidc/impl/DefaultOidcUrlParserTest.kt | 2 +- 12 files changed, 78 insertions(+), 13 deletions(-) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index 05898c75f38..def1f332539 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -111,7 +111,7 @@ class IntentResolverTest { @Test fun `test resolve oidc`() { val sut = createIntentResolver( - oidcIntentResolverResult = { OidcAction.GoBack }, + oidcIntentResolverResult = { OidcAction.GoBack() }, ) val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { action = Intent.ACTION_VIEW @@ -120,7 +120,7 @@ class IntentResolverTest { val result = sut.resolve(intent) assertThat(result).isEqualTo( ResolvedIntent.Oidc( - oidcAction = OidcAction.GoBack + oidcAction = OidcAction.GoBack() ) ) } diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 3873ea70d76..37f3d3f64c0 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.permissions.api) + implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.qrcode) implementation(projects.libraries.oidc.api) implementation(projects.libraries.uiUtils) @@ -62,6 +63,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.wellknown.test) testImplementation(projects.tests.testutils) testReleaseImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index a5522db8704..de9f4bbfaad 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -87,7 +87,7 @@ class LoginFlowNode( // by pressing back or by closing the Custom Chrome Tab. lifecycleScope.launch { delay(5000) - oidcActionFlow.post(OidcAction.GoBack) + oidcActionFlow.post(OidcAction.GoBack(toUnblock = true)) } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt index 14bc64bdf34..82ee87c3721 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt @@ -94,14 +94,14 @@ class LoginHelper( } private suspend fun onOidcAction(oidcAction: OidcAction) { - if (oidcAction is OidcAction.GoBack && loginModeState.value !is AsyncData.Loading) { + if (oidcAction is OidcAction.GoBack && oidcAction.toUnblock && loginModeState.value !is AsyncData.Loading) { // Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode. // This can happen if there is an error, for instance attempt to login again on the same account. return } loginModeState.value = AsyncData.Loading() when (oidcAction) { - OidcAction.GoBack -> { + is OidcAction.GoBack -> { authenticationService.cancelOidcLogin() .onSuccess { loginModeState.value = AsyncData.Uninitialized diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index b2f80e4ffec..3978d3be6e6 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -117,7 +117,7 @@ class ConfirmAccountProviderPresenterTest { assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) authenticationService.givenOidcCancelError(AN_EXCEPTION) - defaultOidcActionFlow.post(OidcAction.GoBack) + defaultOidcActionFlow.post(OidcAction.GoBack()) val cancelFailureState = awaitItem() assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java) } @@ -144,7 +144,30 @@ class ConfirmAccountProviderPresenterTest { assertThat(successState.submitEnabled).isFalse() assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) - defaultOidcActionFlow.post(OidcAction.GoBack) + defaultOidcActionFlow.post(OidcAction.GoBack()) + val cancelFinalState = awaitItem() + assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + } + } + + @Test + fun `present - oidc - cancel to unblock`() = runTest { + val authenticationService = FakeMatrixAuthenticationService() + val defaultOidcActionFlow = FakeOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + authenticationService.givenHomeserver(A_HOMESERVER_OIDC) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + defaultOidcActionFlow.post(OidcAction.GoBack(toUnblock = true)) val cancelFinalState = awaitItem() assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 17e8eb1dbd6..16f6c649fa8 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -29,6 +29,9 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test @@ -79,10 +82,27 @@ class OnBoardingPresenterTest { assertThat(initialState.productionApplicationName).isEqualTo("B") assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) assertThat(initialState.canReportBug).isFalse() + assertThat(initialState.isAddingAccount).isFalse() assertThat(awaitItem().canLoginWithQrCode).isTrue() } } + @Test + fun `present - initial state adding account`() = runTest { + val presenter = createPresenter( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData() + ) + ) + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isAddingAccount).isTrue() + } + } + @Test fun `present - on boarding logo`() = runTest { val presenter = createPresenter( @@ -236,6 +256,7 @@ private fun createPresenter( rageshakeFeatureAvailability: () -> Flow = { flowOf(true) }, loginHelper: LoginHelper = createLoginHelper(), onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null }, + sessionStore: SessionStore = InMemorySessionStore(), ) = OnBoardingPresenter( params = params, buildMeta = buildMeta, @@ -247,6 +268,7 @@ private fun createPresenter( rageshakeFeatureAvailability = rageshakeFeatureAvailability, loginHelper = loginHelper, onBoardingLogoResIdProvider = onBoardingLogoResIdProvider, + sessionStore = sessionStore, ) fun createLoginHelper( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt index 8ac42b4c936..2f27e2fb2d1 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt @@ -25,6 +25,7 @@ import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -50,6 +51,21 @@ class OnboardingViewTest { } } + @Test + fun `when can go back - clicking on back calls the expected callback`() { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + isAddingAccount = true, + eventSink = eventSink, + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + @Test fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() { val eventSink = EventsRecorder(expectEvents = false) @@ -235,6 +251,7 @@ class OnboardingViewTest { private fun AndroidComposeTestRule.setOnboardingView( state: OnBoardingState, + onBackClick: () -> Unit = EnsureNeverCalled(), onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(), onCreateAccount: () -> Unit = EnsureNeverCalled(), @@ -247,6 +264,7 @@ class OnboardingViewTest { setContent { OnBoardingView( state = state, + onBackClick = onBackClick, onSignInWithQrCode = onSignInWithQrCode, onSignIn = onSignIn, onCreateAccount = onCreateAccount, diff --git a/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcAction.kt b/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcAction.kt index abd83d098f0..fc464e9ee2a 100644 --- a/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcAction.kt +++ b/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcAction.kt @@ -8,6 +8,6 @@ package io.element.android.libraries.oidc.api sealed interface OidcAction { - data object GoBack : OidcAction + data class GoBack(val toUnblock: Boolean = false) : OidcAction data class Success(val url: String) : OidcAction } diff --git a/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/OidcUrlParser.kt b/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/OidcUrlParser.kt index 05257d2b0af..1e9b6953a83 100644 --- a/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/OidcUrlParser.kt +++ b/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/OidcUrlParser.kt @@ -36,7 +36,7 @@ class DefaultOidcUrlParser( */ override fun parse(url: String): OidcAction? { if (url.startsWith(oidcRedirectUrlProvider.provide()).not()) return null - if (url.contains("error=access_denied")) return OidcAction.GoBack + if (url.contains("error=access_denied")) return OidcAction.GoBack() if (url.contains("code=")) return OidcAction.Success(url) // Other case not supported, let's crash the app for now diff --git a/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlowTest.kt b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlowTest.kt index 3b56f28c5a6..51017f0af04 100644 --- a/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlowTest.kt +++ b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlowTest.kt @@ -24,10 +24,10 @@ class DefaultOidcActionFlowTest { data.add(action) } } - sut.post(OidcAction.GoBack) + sut.post(OidcAction.GoBack()) delay(1) sut.reset() delay(1) - assertThat(data).containsExactly(OidcAction.GoBack, null) + assertThat(data).containsExactly(OidcAction.GoBack(), null) } } diff --git a/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolverTest.kt b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolverTest.kt index e48e0c2e1ee..48595452d24 100644 --- a/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolverTest.kt +++ b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolverTest.kt @@ -29,7 +29,7 @@ class DefaultOidcIntentResolverTest { data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri() } val result = sut.resolve(intent) - assertThat(result).isEqualTo(OidcAction.GoBack) + assertThat(result).isEqualTo(OidcAction.GoBack()) } @Test diff --git a/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcUrlParserTest.kt b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcUrlParserTest.kt index e40424ca0ef..7ec03a258ec 100644 --- a/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcUrlParserTest.kt +++ b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcUrlParserTest.kt @@ -31,7 +31,7 @@ class DefaultOidcUrlParserTest { fun `test cancel url`() { val sut = createDefaultOidcUrlParser() val aCancelUrl = "$FAKE_REDIRECT_URL?error=access_denied&state=IFF1UETGye2ZA8pO" - assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack) + assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack()) } @Test From 7e6af0f491b70e8bd5b04abbcf91de4397f4e171 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 12:16:49 +0200 Subject: [PATCH 30/71] Fix tests --- .../impl/DatabaseSessionStore.kt | 2 +- .../impl/DatabaseSessionStoreTest.kt | 46 ++++++++++++------- .../libraries/sessionstorage/impl/Fixtures.kt | 4 ++ 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index a42f807253b..f1e9f336567 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -134,7 +134,7 @@ class DatabaseSessionStore( return database.sessionDataQueries.selectLatest() .executeAsOneOrNull() ?.lastUsageIndex - ?: 0L + ?: -1L } override suspend fun getLatestSession(): SessionData? { diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index 0ab3950c4c4..d0acaa3d836 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.session.SessionData import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.LoginType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -122,7 +123,7 @@ class DatabaseSessionStoreTest { } @Test - fun `update session update all fields except loginTimestamp`() = runTest { + fun `update session update all fields except info used by the application`() = runTest { val firstSessionData = SessionData( userId = "userId", deviceId = "deviceId", @@ -137,6 +138,10 @@ class DatabaseSessionStoreTest { passphrase = "aPassphrase", sessionPath = "sessionPath", cachePath = "cachePath", + position = 0, + lastUsageIndex = 0, + userDisplayName = "userDisplayName", + userAvatarUrl = "userAvatarUrl", ) val secondSessionData = SessionData( userId = "userId", @@ -150,8 +155,12 @@ class DatabaseSessionStoreTest { isTokenValid = 1, loginType = null, passphrase = "aPassphraseAltered", - sessionPath = "sessionPath", - cachePath = "cachePath", + sessionPath = "sessionPathAltered", + cachePath = "cachePathAltered", + position = 1, + lastUsageIndex = 1, + userDisplayName = "userDisplayNameAltered", + userAvatarUrl = "userAvatarUrlAltered", ) assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId) assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp) @@ -172,6 +181,11 @@ class DatabaseSessionStoreTest { assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp) assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData) assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase) + // Check that application data have not been altered + assertThat(alteredSession.position).isEqualTo(firstSessionData.position) + assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex) + assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName) + assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl) } @Test @@ -186,10 +200,14 @@ class DatabaseSessionStoreTest { loginTimestamp = 1, oidcData = "aOidcData", isTokenValid = 1, - loginType = null, + loginType = LoginType.PASSWORD.name, passphrase = "aPassphrase", sessionPath = "sessionPath", cachePath = "cachePath", + position = 0, + lastUsageIndex = 0, + userDisplayName = "userDisplayName", + userAvatarUrl = "userAvatarUrl", ) val secondSessionData = SessionData( userId = "userIdUnknown", @@ -201,10 +219,14 @@ class DatabaseSessionStoreTest { loginTimestamp = 2, oidcData = "aOidcDataAltered", isTokenValid = 1, - loginType = null, + loginType = LoginType.PASSWORD.name, passphrase = "aPassphraseAltered", - sessionPath = "sessionPath", - cachePath = "cachePath", + sessionPath = "sessionPathAltered", + cachePath = "cachePathAltered", + position = 1, + lastUsageIndex = 1, + userDisplayName = "userDisplayNameAltered", + userAvatarUrl = "userAvatarUrlAltered", ) assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId) @@ -214,14 +236,6 @@ class DatabaseSessionStoreTest { // Get the session and check that it has not been altered val notAlteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel() - assertThat(notAlteredSession.userId).isEqualTo(firstSessionData.userId) - assertThat(notAlteredSession.deviceId).isEqualTo(firstSessionData.deviceId) - assertThat(notAlteredSession.accessToken).isEqualTo(firstSessionData.accessToken) - assertThat(notAlteredSession.refreshToken).isEqualTo(firstSessionData.refreshToken) - assertThat(notAlteredSession.homeserverUrl).isEqualTo(firstSessionData.homeserverUrl) - assertThat(notAlteredSession.slidingSyncProxy).isEqualTo(firstSessionData.slidingSyncProxy) - assertThat(notAlteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp) - assertThat(notAlteredSession.oidcData).isEqualTo(firstSessionData.oidcData) - assertThat(notAlteredSession.passphrase).isEqualTo(firstSessionData.passphrase) + assertThat(notAlteredSession).isEqualTo(firstSessionData) } } diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt index 3251dfc5d9e..e8713dac1aa 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt @@ -24,4 +24,8 @@ internal fun aSessionData() = SessionData( passphrase = null, sessionPath = "sessionPath", cachePath = "cachePath", + position = 0, + lastUsageIndex = 0, + userDisplayName = null, + userAvatarUrl = null, ) From 6a954bf5eb4ed176975e19752dd1b3da0a0a9c19 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 13:34:19 +0200 Subject: [PATCH 31/71] Fix tests --- .../impl/history/PushHistoryPresenter.kt | 2 +- .../impl/history/PushHistoryPresenterTest.kt | 57 +++++++++++++++++++ .../impl/history/PushHistoryViewTest.kt | 24 +++----- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt index e2b337dc13f..912cbc45c99 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt @@ -27,7 +27,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -interface PushHistoryNavigator { +fun interface PushHistoryNavigator { fun navigateTo(roomId: RoomId, eventId: EventId) } diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt index 313735b6e66..9c1cdee25cc 100644 --- a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt @@ -11,9 +11,19 @@ package io.element.android.libraries.troubleshoot.impl.history import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.test.FakePushService +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -29,6 +39,7 @@ class PushHistoryPresenterTest { assertThat(initialState.pushHistoryItems).isEmpty() assertThat(initialState.showOnlyErrors).isFalse() assertThat(initialState.resetAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.showNotSameAccountError).isFalse() } } @@ -119,11 +130,57 @@ class PushHistoryPresenterTest { } } + @Test + fun `present - item click current account`() = runTest { + val pushHistoryNavigatorResult = lambdaRecorder { _, _ -> } + val presenter = createPushHistoryPresenter( + pushHistoryNavigator = { roomId, eventId -> + pushHistoryNavigatorResult(roomId, eventId) + } + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink( + PushHistoryEvents.NavigateTo( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + ) + ) + pushHistoryNavigatorResult.assertions() + .isCalledOnce() + .with(value(A_ROOM_ID), value(AN_EVENT_ID)) + } + } + + @Test + fun `present - item click not current account`() = runTest { + val presenter = createPushHistoryPresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink( + PushHistoryEvents.NavigateTo( + sessionId = A_SESSION_ID_2, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + ) + ) + assertThat(awaitItem().showNotSameAccountError).isTrue() + // Reset error + initialState.eventSink(PushHistoryEvents.ClearDialog) + assertThat(awaitItem().showNotSameAccountError).isFalse() + } + } + private fun createPushHistoryPresenter( + pushHistoryNavigator: PushHistoryNavigator = PushHistoryNavigator { _, _ -> lambdaError() }, pushService: PushService = FakePushService(), + matrixClient: MatrixClient = FakeMatrixClient(), ): PushHistoryPresenter { return PushHistoryPresenter( + pushHistoryNavigator = pushHistoryNavigator, pushService = pushService, + matrixClient = matrixClient, ) } } diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt index 5c98b2c21a4..ada8b61f351 100644 --- a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt @@ -14,20 +14,14 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_FORMATTED_DATE import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled -import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParams import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -103,9 +97,8 @@ class PushHistoryViewTest { } @Test - fun `clicking on a valid event invokes the expected callback`() { - val eventsRecorder = EventsRecorder(expectEvents = false) - val onItemClick = lambdaRecorder { _, _, _ -> } + fun `clicking on a valid event emits the expected Event`() { + val eventsRecorder = EventsRecorder() rule.setPushHistoryView( aPushHistoryState( pushHistoryItems = listOf( @@ -118,25 +111,26 @@ class PushHistoryViewTest { ), eventSink = eventsRecorder, ), - onItemClick = onItemClick, ) rule.onNodeWithText(A_FORMATTED_DATE).performClick() - onItemClick.assertions() - .isCalledOnce() - .with(value(A_SESSION_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + eventsRecorder.assertSingle( + PushHistoryEvents.NavigateTo( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + ) + ) } } private fun AndroidComposeTestRule.setPushHistoryView( state: PushHistoryState, onBackClick: () -> Unit = EnsureNeverCalled(), - onItemClick: (SessionId, RoomId, EventId) -> Unit = EnsureNeverCalledWithThreeParams(), ) { setContent { PushHistoryView( state = state, onBackClick = onBackClick, - onItemClick = onItemClick, ) } } From b9b22c2690fd6366381927a2fae71480531cd8b7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 13:40:28 +0200 Subject: [PATCH 32/71] Fix tests --- .../analytics/impl/DefaultAnalyticsService.kt | 8 +++++-- .../impl/DefaultAnalyticsServiceTest.kt | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt index 385524464a5..a6f790c72ba 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt @@ -17,6 +17,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.services.analytics.api.AnalyticsService @@ -40,6 +41,7 @@ class DefaultAnalyticsService( @AppCoroutineScope private val coroutineScope: CoroutineScope, private val sessionObserver: SessionObserver, + private val sessionStore: SessionStore, ) : AnalyticsService, SessionListener { // Cache for the store values private val userConsent = AtomicBoolean(false) @@ -84,8 +86,10 @@ class DefaultAnalyticsService( } override suspend fun onSessionDeleted(userId: String) { - // Delete the store - // analyticsStore.reset() + // Delete the store when the last session is deleted + if (sessionStore.getAllSessions().isEmpty()) { + analyticsStore.reset() + } } private fun observeUserConsent() { diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt index e05a6e4208f..e002a35a3c6 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt @@ -16,7 +16,10 @@ import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.PollEnd import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver import io.element.android.services.analytics.impl.store.AnalyticsStore import io.element.android.services.analytics.impl.store.FakeAnalyticsStore @@ -167,7 +170,7 @@ class DefaultAnalyticsServiceTest { } @Test - fun `when a session is deleted, the store is reset`() = runTest { + fun `when the last session is deleted, the store is reset`() = runTest { val resetLambda = lambdaRecorder { } val store = FakeAnalyticsStore( resetLambda = resetLambda, @@ -180,6 +183,23 @@ class DefaultAnalyticsServiceTest { resetLambda.assertions().isCalledOnce() } + @Test + fun `when a session is deleted, the store is not reset`() = runTest { + val resetLambda = lambdaRecorder { } + val store = FakeAnalyticsStore( + resetLambda = resetLambda, + ) + val sut = createDefaultAnalyticsService( + coroutineScope = backgroundScope, + analyticsStore = store, + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + ) + ) + sut.onSessionDeleted("userId") + resetLambda.assertions().isNeverCalled() + } + @Test fun `when reset is invoked, the user consent is reset`() = runTest { val store = FakeAnalyticsStore( @@ -272,11 +292,13 @@ class DefaultAnalyticsServiceTest { ), analyticsStore: AnalyticsStore = FakeAnalyticsStore(), sessionObserver: SessionObserver = NoOpSessionObserver(), + sessionStore: SessionStore = InMemorySessionStore(), ) = DefaultAnalyticsService( analyticsProviders = analyticsProviders, analyticsStore = analyticsStore, coroutineScope = coroutineScope, sessionObserver = sessionObserver, + sessionStore = sessionStore, ).also { // Wait for the service to be ready delay(1) From 18d3d5b251ca5ae453ec925744786f7be2a6cf1b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 13:50:25 +0200 Subject: [PATCH 33/71] Update Multi accounts flag details. --- .../android/libraries/featureflag/api/FeatureFlags.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 732854a3f56..96c452a7905 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -102,8 +102,9 @@ enum class FeatureFlags( ), MultiAccount( key = "feature.multi_account", - title = "Multi account", - description = "Allow the application to connect to multiple accounts at the same time. Under active development!", + title = "Multi accounts", + description = "Allow the application to connect to multiple accounts at the same time." + + "\n\nWARNING: this feature is EXPERIMENTAL and UNSTABLE.", defaultValue = { false }, isFinished = false, ), From 72113cde35bdd76474e02f718dfb9d2bd116b3eb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 15:33:33 +0200 Subject: [PATCH 34/71] Add missing test on DatabaseSessionStore --- .../impl/DatabaseSessionStoreTest.kt | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index d0acaa3d836..8e44a807be1 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -122,6 +122,82 @@ class DatabaseSessionStoreTest { assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() } + @Test + fun `updateUserProfile does nothing if the session is not found`() = runTest { + databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl") + assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() + } + + @Test + fun `updateUserProfile update the data`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl") + val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne() + assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName") + assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl") + } + + @Test + fun `setLatestSession is no op when the session is already the latest session`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + val session = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne() + assertThat(session.lastUsageIndex).isEqualTo(0) + assertThat(session.position).isEqualTo(0) + databaseSessionStore.setLatestSession(aSessionData.userId) + assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne().lastUsageIndex).isEqualTo(0) + } + + @Test + fun `setLatestSession is no op when the session is not found`() = runTest { + databaseSessionStore.setLatestSession(aSessionData.userId) + } + + @Test + fun `multi session test`() = runTest { + databaseSessionStore.addSession(aSessionData.toApiModel()) + val session = databaseSessionStore.getSession(aSessionData.userId)!! + assertThat(session.lastUsageIndex).isEqualTo(0) + assertThat(session.position).isEqualTo(0) + val secondSessionData = aSessionData.copy( + userId = "otherUserId", + position = 1, + lastUsageIndex = 1, + ) + databaseSessionStore.addSession(secondSessionData.toApiModel()) + val secondSession = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne() + assertThat(secondSession.lastUsageIndex).isEqualTo(1) + assertThat(secondSession.position).isEqualTo(1) + // Set the first session as the latest + databaseSessionStore.setLatestSession(aSessionData.userId) + val firstSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne() + assertThat(firstSession.lastUsageIndex).isEqualTo(2) + assertThat(firstSession.position).isEqualTo(0) + // Check that the second session has not been altered + val secondSession2 = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne() + assertThat(secondSession2.lastUsageIndex).isEqualTo(1) + assertThat(secondSession2.position).isEqualTo(1) + } + + @Test + fun `test sessionsFlow()`() = runTest { + databaseSessionStore.sessionsFlow().test { + assertThat(awaitItem()).isEmpty() + databaseSessionStore.addSession(aSessionData.toApiModel()) + assertThat(awaitItem().size).isEqualTo(1) + val secondSessionData = aSessionData.copy( + userId = "otherUserId", + position = 1, + lastUsageIndex = 1, + ) + databaseSessionStore.addSession(secondSessionData.toApiModel()) + assertThat(awaitItem().size).isEqualTo(2) + databaseSessionStore.removeSession(aSessionData.userId) + assertThat(awaitItem().size).isEqualTo(1) + databaseSessionStore.removeSession(secondSessionData.userId) + assertThat(awaitItem()).isEmpty() + } + } + @Test fun `update session update all fields except info used by the application`() = runTest { val firstSessionData = SessionData( From 97aff0f2c6183cc1974ed3f3b7971b62a27b51e1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 15:40:54 +0200 Subject: [PATCH 35/71] Add missing preview on LoginModeView --- .../features/login/impl/login/LoginModeView.kt | 3 +-- .../impl/login/LoginModeViewErrorProvider.kt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt index b1528176911..c3fe5eac47f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.login.impl.R import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog import io.element.android.features.login.impl.error.ChangeServerError -import io.element.android.features.login.impl.error.ChangeServerErrorProvider import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.libraries.androidutils.system.openGooglePlay import io.element.android.libraries.architecture.AsyncData @@ -120,7 +119,7 @@ fun LoginModeView( @PreviewsDayNight @Composable -internal fun LoginModeViewPreview(@PreviewParameter(ChangeServerErrorProvider::class) error: ChangeServerError) { +internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::class) error: Throwable) { ElementPreview { LoginModeView( loginMode = AsyncData.Failure(error), diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt new file mode 100644 index 00000000000..dd0a7f353c8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt @@ -0,0 +1,18 @@ +/* + * 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.login.impl.login + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.error.ChangeServerErrorProvider +import io.element.android.libraries.matrix.api.auth.AuthenticationException + +class LoginModeViewErrorProvider : PreviewParameterProvider { + override val values: Sequence + get() = ChangeServerErrorProvider().values + + AuthenticationException.AccountAlreadyLoggedIn("@alice:matrix.org") +} From 62e4e58e5c50ef9efeaf1405c57d2271bbdb1cf4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 15:45:14 +0200 Subject: [PATCH 36/71] Remove dead code. --- .../accountselect/impl/AccountSelectEvents.kt | 10 ---------- .../accountselect/impl/AccountSelectPresenter.kt | 3 --- .../libraries/accountselect/impl/AccountSelectState.kt | 1 - .../accountselect/impl/AccountSelectStateProvider.kt | 2 -- 4 files changed, 16 deletions(-) delete mode 100644 libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectEvents.kt diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectEvents.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectEvents.kt deleted file mode 100644 index 87ed2d2f795..00000000000 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectEvents.kt +++ /dev/null @@ -1,10 +0,0 @@ -/* - * 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.libraries.accountselect.impl - -sealed interface AccountSelectEvents diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt index 0c5fa3d68d3..dde07e7e38c 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -37,11 +37,8 @@ class AccountSelectPresenter( .toPersistentList() } - fun handleEvents(event: AccountSelectEvents) {} - return AccountSelectState( accounts = accounts, - eventSink = ::handleEvents ) } } diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt index eb8c5c85767..feaedaf90d1 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt @@ -12,5 +12,4 @@ import kotlinx.collections.immutable.ImmutableList data class AccountSelectState( val accounts: ImmutableList, - val eventSink: (AccountSelectEvents) -> Unit ) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt index c7007fea654..3dc0a22b9c6 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt @@ -22,8 +22,6 @@ open class AccountSelectStateProvider : PreviewParameterProvider = listOf(), - eventSink: (AccountSelectEvents) -> Unit = {}, ) = AccountSelectState( accounts = accounts.toPersistentList(), - eventSink = eventSink, ) From 540f8d62f70780d6ac2bf3c778abe1d5cb19b8fa Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 15:46:08 +0200 Subject: [PATCH 37/71] Add missing preview on PushHistoryView --- .../troubleshoot/impl/history/PushHistoryStateProvider.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt index 6b0a1c45bfa..11d9c509cc7 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt @@ -40,6 +40,9 @@ open class PushHistoryStateProvider : PreviewParameterProvider aPushHistoryState( resetAction = AsyncAction.ConfirmingNoParams, ), + aPushHistoryState( + showNotSameAccountError = true, + ), ) } From 4299e12b04634223ce32f7d429988be797f632cc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 15:54:51 +0200 Subject: [PATCH 38/71] Document API. --- .../sessionstorage/api/SessionStore.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index 10dfd49e759..2389f1834c2 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -11,8 +11,22 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map interface SessionStore { + /** + * A flow emitting the current logged in state. + * If there is at least one session, the state is [LoggedInState.LoggedIn] with the latest used session. + * If there is no session, the state is [LoggedInState.NotLoggedIn]. + */ fun isLoggedIn(): Flow + + /** + * Return a flow of all sessions ordered by last usage descending. + */ fun sessionsFlow(): Flow> + + /** + * Add a new session. If other sessions exist, the new one will be set as the latest used one, and + * the added session position will be set to a value higher than the other session positions. + */ suspend fun addSession(sessionData: SessionData) /** @@ -20,11 +34,35 @@ interface SessionStore { * No op if userId is not found in DB. */ suspend fun updateData(sessionData: SessionData) + + /** + * Update the user profile info of the session matching the userId. + */ suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) + + /** + * Get the session data matching the userId, or null if not found. + */ suspend fun getSession(sessionId: String): SessionData? + + /** + * Get all sessions ordered by last usage descending. + */ suspend fun getAllSessions(): List + + /** + * Get the latest session, or null if no session exists. + */ suspend fun getLatestSession(): SessionData? + + /** + * Set the session with [sessionId] as the latest used one. + */ suspend fun setLatestSession(sessionId: String) + + /** + * Remove the session matching the sessionId. + */ suspend fun removeSession(sessionId: String) } From b2df76e87ee74e2befea6ce689bdc2bf9e307aab Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 15:59:36 +0200 Subject: [PATCH 39/71] Rename API and update test. --- .../impl/auth/RustMatrixAuthenticationService.kt | 2 +- .../libraries/sessionstorage/api/SessionStore.kt | 2 +- .../sessionstorage/impl/DatabaseSessionStore.kt | 2 +- .../impl/DatabaseSessionStoreTest.kt | 15 +++++++++++---- .../sessionstorage/test/InMemorySessionStore.kt | 2 +- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 3a991c6f648..59e4ba9f022 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -85,7 +85,7 @@ class RustMatrixAuthenticationService( } override fun loggedInStateFlow(): Flow { - return sessionStore.isLoggedIn() + return sessionStore.loggedInStateFlow() } override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index 2389f1834c2..9d9f143e15a 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -16,7 +16,7 @@ interface SessionStore { * If there is at least one session, the state is [LoggedInState.LoggedIn] with the latest used session. * If there is no session, the state is [LoggedInState.NotLoggedIn]. */ - fun isLoggedIn(): Flow + fun loggedInStateFlow(): Flow /** * Return a flow of all sessions ordered by last usage descending. diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index f1e9f336567..33ee630d24d 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -33,7 +33,7 @@ class DatabaseSessionStore( ) : SessionStore { private val sessionDataMutex = Mutex() - override fun isLoggedIn(): Flow { + override fun loggedInStateFlow(): Flow { return database.sessionDataQueries.selectLatest() .asFlow() .mapToOneOrNull(dispatchers.io) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index 8e44a807be1..7d264f42dba 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -55,12 +55,19 @@ class DatabaseSessionStoreTest { } @Test - fun `isLoggedIn emits true while there are sessions in the DB`() = runTest { - databaseSessionStore.isLoggedIn().test { + fun `loggedInStateFlow emits LoggedIn while there are sessions in the DB`() = runTest { + databaseSessionStore.loggedInStateFlow().test { assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn) - database.sessionDataQueries.insertSessionData(aSessionData) + databaseSessionStore.addSession(aSessionData.toApiModel()) + assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true)) + // Add a second session + databaseSessionStore.addSession(aSessionData.copy(userId = "otherUserId").toApiModel()) + assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = "otherUserId", isTokenValid = true)) + // Remove the second session + databaseSessionStore.removeSession("otherUserId") assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true)) - database.sessionDataQueries.removeSession(aSessionData.userId) + // Remove the first session + databaseSessionStore.removeSession(aSessionData.userId) assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn) } } diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt index c28f87bb543..c8f3078e7af 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -22,7 +22,7 @@ class InMemorySessionStore( ) : SessionStore { private val sessionDataListFlow = MutableStateFlow(initialList) - override fun isLoggedIn(): Flow { + override fun loggedInStateFlow(): Flow { return sessionDataListFlow.map { if (it.isEmpty()) { LoggedInState.NotLoggedIn From 40718cd87bdae767415756fe6e38c9d79398f4ca Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 16:04:08 +0200 Subject: [PATCH 40/71] Remove MatrixAuthenticationService.loggedInStateFlow() --- .../element/android/appnav/root/RootNavStateFlowFactory.kt | 6 +++--- .../matrix/api/auth/MatrixAuthenticationService.kt | 3 --- .../matrix/impl/auth/RustMatrixAuthenticationService.kt | 4 ---- .../matrix/test/auth/FakeMatrixAuthenticationService.kt | 7 ------- 4 files changed, 3 insertions(+), 17 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 962bac614b0..dfa478d6be0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -12,9 +12,9 @@ import com.bumble.appyx.core.state.SavedStateMap import dev.zacsweers.metro.Inject import io.element.android.appnav.di.MatrixSessionCache import io.element.android.features.preferences.api.CacheService -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow @@ -28,7 +28,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact */ @Inject class RootNavStateFlowFactory( - private val authenticationService: MatrixAuthenticationService, + private val sessionStore: SessionStore, private val cacheService: CacheService, private val matrixSessionCache: MatrixSessionCache, private val imageLoaderHolder: ImageLoaderHolder, @@ -39,7 +39,7 @@ class RootNavStateFlowFactory( fun create(savedStateMap: SavedStateMap?): Flow { return combine( cacheIndexFlow(savedStateMap), - authenticationService.loggedInStateFlow(), + sessionStore.loggedInStateFlow(), ) { cacheIndex, loggedInState -> RootNavState( cacheIndex = cacheIndex, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index d1c47ae663c..51778211cc6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -13,12 +13,9 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.sessionstorage.api.LoggedInState -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface MatrixAuthenticationService { - fun loggedInStateFlow(): Flow suspend fun getLatestSessionId(): SessionId? /** diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 59e4ba9f022..b02d4a0f8bd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -84,10 +84,6 @@ class RustMatrixAuthenticationService( .also { sessionPaths = it } } - override fun loggedInStateFlow(): Flow { - return sessionStore.loggedInStateFlow() - } - override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { sessionStore.getLatestSession()?.userId?.let { SessionId(it) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt index f236c37da81..79f9942628d 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -19,14 +19,11 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.simulateLongTask -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOf val A_OIDC_DATA = OidcDetails(url = "a-url") @@ -46,10 +43,6 @@ class FakeMatrixAuthenticationService( var getLatestSessionIdLambda: (() -> SessionId?) = { null } - override fun loggedInStateFlow(): Flow { - return flowOf(LoggedInState.NotLoggedIn) - } - override suspend fun getLatestSessionId(): SessionId? = getLatestSessionIdLambda() override suspend fun restoreSession(sessionId: SessionId): Result { From dd677e679d6e3eb430744051475be067b11d7a2d Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 5 Sep 2025 14:21:33 +0000 Subject: [PATCH 41/71] Update screenshots --- .../features.login.impl.login_LoginModeView_Day_5_en.png | 3 +++ .../features.login.impl.login_LoginModeView_Night_5_en.png | 3 +++ ...ries.troubleshoot.impl.history_PushHistoryView_Day_3_en.png | 3 +++ ...es.troubleshoot.impl.history_PushHistoryView_Night_3_en.png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_3_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_5_en.png new file mode 100644 index 00000000000..2e7635cfdb0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4eaf3d650155e9a779cf796cef116eaba0f1d6722b229332b224475393a88178 +size 16828 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_5_en.png new file mode 100644 index 00000000000..10ca16827e3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.login_LoginModeView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59f062f54833df71be9d7c4e785bb01013a10642e0d863bf7ef2abd8862b93c8 +size 15476 diff --git a/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_3_en.png new file mode 100644 index 00000000000..c77ea23f323 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:692bf4dd932e39d3b3d7c2b234524b9e31a288808b1a5c4574e3a0ca19d6a725 +size 23296 diff --git a/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_3_en.png new file mode 100644 index 00000000000..616133a8684 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.troubleshoot.impl.history_PushHistoryView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:303c3a69b1ed1bcb8bf2253b8a70f2a8c06171e583f43dbc10023d66880c9e6d +size 21309 From 7f822cdc904d375ccc2a5d464737ccf99c5dc2d4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 16:37:05 +0200 Subject: [PATCH 42/71] Remove unused import --- .../matrix/impl/auth/RustMatrixAuthenticationService.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index b02d4a0f8bd..d07f680b1f3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -34,11 +34,9 @@ import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator import io.element.android.libraries.matrix.impl.mapper.toSessionData import io.element.android.libraries.matrix.impl.paths.SessionPaths import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory -import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext From 9ef6e8e87e161e577ff1196c1a596ef1058a8ee7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 16:39:51 +0200 Subject: [PATCH 43/71] Add exception --- .../io/element/android/tests/konsist/KonsistClassNameTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index d24a489c6af..6278528735d 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -51,6 +51,7 @@ class KonsistClassNameTest { .withAllParentsOf(PreviewParameterProvider::class) .withoutName( "AspectRatioProvider", + "LoginModeViewErrorProvider", "OverlapRatioProvider", "TextFileContentProvider", ) From 614ea0d6217ae157ca1ae861397f17ba7805b79d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 Sep 2025 18:51:34 +0200 Subject: [PATCH 44/71] Fix compilation issue after rebase on develop. --- .../android/libraries/accountselect/impl/AccountSelectNode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt index 61eb1c6e7d2..275a3d6470e 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt @@ -15,7 +15,7 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Inject -import io.element.android.anvilannotations.ContributesNode +import io.element.android.annotations.ContributesNode import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.matrix.api.core.SessionId From 3a46fec81986b4e1922c666da5ee17116535c5c9 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 5 Sep 2025 17:07:33 +0000 Subject: [PATCH 45/71] Update screenshots --- ....components_DefaultRoomListTopBarMultiAccount_Day_0_en.png | 3 +++ ...omponents_DefaultRoomListTopBarMultiAccount_Night_0_en.png | 3 +++ ....login.impl.screens.onboarding_OnBoardingView_Day_7_en.png | 3 +++ ...ogin.impl.screens.onboarding_OnBoardingView_Night_7_en.png | 3 +++ ...res.preferences.impl.root_MultiAccountSection_Day_0_en.png | 3 +++ ...s.preferences.impl.root_MultiAccountSection_Night_0_en.png | 3 +++ ...res.preferences.impl.root_PreferencesRootViewDark_0_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_1_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_0_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_1_en.png | 4 ++-- ...ibraries.accountselect.impl_AccountSelectView_Day_0_en.png | 3 +++ ...ibraries.accountselect.impl_AccountSelectView_Day_1_en.png | 3 +++ ...raries.accountselect.impl_AccountSelectView_Night_0_en.png | 3 +++ ...raries.accountselect.impl_AccountSelectView_Night_1_en.png | 3 +++ ...s.designsystem.components.avatar_Avatar_Avatars_105_en.png | 3 +++ ...s.designsystem.components.avatar_Avatar_Avatars_106_en.png | 3 +++ ...s.designsystem.components.avatar_Avatar_Avatars_107_en.png | 3 +++ 17 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_DefaultRoomListTopBarMultiAccount_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_DefaultRoomListTopBarMultiAccount_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_105_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_106_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_107_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_DefaultRoomListTopBarMultiAccount_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_DefaultRoomListTopBarMultiAccount_Day_0_en.png new file mode 100644 index 00000000000..dd337cac3b4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_DefaultRoomListTopBarMultiAccount_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b289624c8461e08c945a254eb629ea536552e2652c7182be21a7bd9f183da022 +size 26185 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_DefaultRoomListTopBarMultiAccount_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_DefaultRoomListTopBarMultiAccount_Night_0_en.png new file mode 100644 index 00000000000..640aebb723c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_DefaultRoomListTopBarMultiAccount_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b86aaf39367bd454a913c4ed6cd17bbaff52e54349e0e6d363d6f84ccdc43c5f +size 23678 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png new file mode 100644 index 00000000000..cdd0ab890ce --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0c1cb36f30969765191d54131c862a06ec749668f79e5cf025f487ddda1ca0c +size 21891 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png new file mode 100644 index 00000000000..f45558ed755 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f25dd05a8be436b2a6d9721080dc460f50bc7a0549885144a285a428a80a2e3f +size 20982 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Day_0_en.png new file mode 100644 index 00000000000..caa492261cf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b77e3ce009dcfac0e41e7f28e5beb934ce39ade1ca85115912a5029b46f47f0 +size 58278 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Night_0_en.png new file mode 100644 index 00000000000..06ecd42cb44 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_MultiAccountSection_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4228cd39352d4efcee45866839ff4b1c63426bfd45af083af7b2f57286c2181f +size 59227 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png index 3024f2cff87..e2eb5d4c164 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a3329563aa6c438335aaadb18cc2767d78eb16c34b616c5e9b14f0d8b66fb032 -size 38106 +oid sha256:f2b08f4ce0aca4ae2aab4bb7323c6dc9fed964b77d6fb40a9bc1d33ab244b150 +size 39309 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png index a23da635422..41e8f5801e3 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7b3d3f464858c9a298461aad3d3a329ac894fbd6206cbc73ec0de16d20e69e3 -size 37946 +oid sha256:faf1f62a227d60447d08aa860f911dc0dc2d249ae3e35023420d833b0a9ed129 +size 39139 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png index fe9ddfe32f8..fb947801500 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0ba38204b90b53b0b03efabc5dd69200089327c22250e9416d8ee2fd2b94537 -size 38915 +oid sha256:12e32bea58f001b0e3191d0b19304214f6bea5ddf828298bd673fdbe66cc6b16 +size 40219 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png index 694ad9d65e1..c01fa655a69 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb9b09711d4538aa350d9c072b31becb223cd2b842d606f405b208e3cc13b77c -size 38968 +oid sha256:801230ed2f7ce355c098287e3f9a66b92a2de443aad704214864162eacc5d56e +size 40268 diff --git a/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Day_0_en.png new file mode 100644 index 00000000000..17cefd666f1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:215df53e6287ac32df6ec3a4b910a40035c8a516e89bdfc7b23aa1d45320ab78 +size 8285 diff --git a/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Day_1_en.png new file mode 100644 index 00000000000..815ac1db6da --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:848f7d81f4bede07d697ad326a0d23845c8446ad49cedc81e985044ad4b45a5c +size 49048 diff --git a/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Night_0_en.png new file mode 100644 index 00000000000..46a95f8bd77 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6bfaebba98b5b9bf083e5d29eb9a2a835ce774e26956b582d01b54a1799e507 +size 8172 diff --git a/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Night_1_en.png new file mode 100644 index 00000000000..8a250d9439c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.accountselect.impl_AccountSelectView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87003f7f0c981f0fee9f0773914e2fd99e18f1001045c13f52bc33dfba2905d6 +size 49941 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_105_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_105_en.png new file mode 100644 index 00000000000..9b1636f3bd5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_105_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab6848052b8bb306bbe8bbd87a62599047f7e530b70a17417b7ba51f2d90ecf5 +size 14278 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_106_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_106_en.png new file mode 100644 index 00000000000..73fb759161b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_106_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ddb1b57c91c8d3adec9b79307d52a36ac4516e7020f2c3eafc9343fd9d9e368 +size 13536 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_107_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_107_en.png new file mode 100644 index 00000000000..73fbe1fcdf8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_107_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f80fd46fb18b92e462e079647b42f6cb8cd101f900120d4927d3101defe2dd36 +size 16213 From 5e8506582477c4b12083023ac116c573f5df07f9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 12 Sep 2025 11:42:13 +0200 Subject: [PATCH 46/71] Fix test --- .../io/element/android/features/home/impl/HomePresenterTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 168caf63faf..8c693d6bbca 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -202,6 +202,9 @@ class HomePresenterTest { fun `present - NavigationBar is hidden when the last space is left`() = runTest { val homeSpacesPresenter = MutablePresenter(aHomeSpacesState()) val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), featureFlagService = FakeFeatureFlagService( initialState = mapOf(FeatureFlags.Space.key to true), ), From 2d77a636058cf086b6c291019d073620dadb8b11 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 17 Sep 2025 09:42:14 +0200 Subject: [PATCH 47/71] Avoid calling getLatestSession() twice --- .../libraries/sessionstorage/impl/DatabaseSessionStore.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 33ee630d24d..d6197d868dd 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -112,7 +112,7 @@ class DatabaseSessionStore( // Already the latest session return } - val lastUsageIndex = getLatestSession()?.lastUsageIndex ?: 0 + val lastUsageIndex = latestSession?.lastUsageIndex ?: 0 val result = database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() ?.toApiModel() From 5d0db44257f8431111bae9860a384e0daa4e6983 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 17 Sep 2025 09:47:01 +0200 Subject: [PATCH 48/71] Rename `matrixUserAndNeighbors` to `currentUserAndNeighbors` --- .../features/home/impl/HomePresenter.kt | 4 +-- .../android/features/home/impl/HomeState.kt | 2 +- .../features/home/impl/HomeStateProvider.kt | 4 +-- .../android/features/home/impl/HomeView.kt | 2 +- .../home/impl/components/RoomListTopBar.kt | 26 +++++++++---------- .../features/home/impl/HomePresenterTest.kt | 6 ++--- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 76e898e6dac..bc2a975ba1d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -58,7 +58,7 @@ class HomePresenter( override fun present(): HomeState { val coroutineState = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() - val matrixUserAndNeighbors by remember { + val currentUserAndNeighbors by remember { combine( client.userProfile.onEach { user -> // Ensure that the profile is always up to date in our @@ -114,7 +114,7 @@ class HomePresenter( } val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() return HomeState( - matrixUserAndNeighbors = matrixUserAndNeighbors, + currentUserAndNeighbors = currentUserAndNeighbors, showAvatarIndicator = showAvatarIndicator, hasNetworkConnection = isOnline, currentHomeNavigationBarItem = currentHomeNavigationBarItem, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index b97f9c597d1..d35412734fb 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -21,7 +21,7 @@ data class HomeState( * The current user of this session, in case of multiple accounts, will contains 3 items, with the * current user in the middle. */ - val matrixUserAndNeighbors: ImmutableList, + val currentUserAndNeighbors: ImmutableList, val showAvatarIndicator: Boolean, val hasNetworkConnection: Boolean, val currentHomeNavigationBarItem: HomeNavigationBarItem, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt index 6f027970530..7ada259e08e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt @@ -51,7 +51,7 @@ open class HomeStateProvider : PreviewParameterProvider { internal fun aHomeState( matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), - matrixUserAndNeighbors: List = listOf(matrixUser), + currentUserAndNeighbors: List = listOf(matrixUser), showAvatarIndicator: Boolean = false, hasNetworkConnection: Boolean = true, snackbarMessage: SnackbarMessage? = null, @@ -63,7 +63,7 @@ internal fun aHomeState( directLogoutState: DirectLogoutState = aDirectLogoutState(), eventSink: (HomeEvents) -> Unit = {} ) = HomeState( - matrixUserAndNeighbors = matrixUserAndNeighbors.toPersistentList(), + currentUserAndNeighbors = currentUserAndNeighbors.toPersistentList(), showAvatarIndicator = showAvatarIndicator, hasNetworkConnection = hasNetworkConnection, snackbarMessage = snackbarMessage, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index b1d1ebf5aa0..aa4742f074d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -171,7 +171,7 @@ private fun HomeScaffold( topBar = { RoomListTopBar( title = stringResource(state.currentHomeNavigationBarItem.labelRes), - matrixUserAndNeighbors = state.matrixUserAndNeighbors, + currentUserAndNeighbors = state.currentUserAndNeighbors, showAvatarIndicator = state.showAvatarIndicator, areSearchResultsDisplayed = roomListState.searchState.isSearchActive, onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) }, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt index 1d51e745b42..212ba6f29be 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt @@ -78,7 +78,7 @@ import kotlinx.collections.immutable.toPersistentList @Composable fun RoomListTopBar( title: String, - matrixUserAndNeighbors: ImmutableList, + currentUserAndNeighbors: ImmutableList, showAvatarIndicator: Boolean, areSearchResultsDisplayed: Boolean, onToggleSearch: () -> Unit, @@ -94,7 +94,7 @@ fun RoomListTopBar( ) { DefaultRoomListTopBar( title = title, - matrixUserAndNeighbors = matrixUserAndNeighbors, + currentUserAndNeighbors = currentUserAndNeighbors, showAvatarIndicator = showAvatarIndicator, areSearchResultsDisplayed = areSearchResultsDisplayed, onOpenSettings = onOpenSettings, @@ -114,7 +114,7 @@ fun RoomListTopBar( @Composable private fun DefaultRoomListTopBar( title: String, - matrixUserAndNeighbors: ImmutableList, + currentUserAndNeighbors: ImmutableList, showAvatarIndicator: Boolean, areSearchResultsDisplayed: Boolean, scrollBehavior: TopAppBarScrollBehavior, @@ -165,7 +165,7 @@ private fun DefaultRoomListTopBar( }, navigationIcon = { NavigationIcon( - matrixUserAndNeighbors = matrixUserAndNeighbors, + currentUserAndNeighbors = currentUserAndNeighbors, showAvatarIndicator = showAvatarIndicator, onAccountSwitch = onAccountSwitch, onClick = onOpenSettings, @@ -255,26 +255,26 @@ private fun DefaultRoomListTopBar( @Composable private fun NavigationIcon( - matrixUserAndNeighbors: ImmutableList, + currentUserAndNeighbors: ImmutableList, showAvatarIndicator: Boolean, onAccountSwitch: (SessionId) -> Unit, onClick: () -> Unit, ) { - if (matrixUserAndNeighbors.size == 1) { + if (currentUserAndNeighbors.size == 1) { AccountIcon( - matrixUser = matrixUserAndNeighbors.single(), + matrixUser = currentUserAndNeighbors.single(), isCurrentAccount = true, showAvatarIndicator = showAvatarIndicator, onClick = onClick, ) } else { // Render a vertical pager - val pagerState = rememberPagerState(initialPage = 1) { matrixUserAndNeighbors.size } + val pagerState = rememberPagerState(initialPage = 1) { currentUserAndNeighbors.size } // Listen to page changes and switch account if needed val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch) LaunchedEffect(pagerState) { snapshotFlow { pagerState.settledPage }.collect { page -> - latestOnAccountSwitch(SessionId(matrixUserAndNeighbors[page].userId.value)) + latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value)) } } VerticalPager( @@ -282,7 +282,7 @@ private fun NavigationIcon( modifier = Modifier.height(48.dp), ) { page -> AccountIcon( - matrixUser = matrixUserAndNeighbors[page], + matrixUser = currentUserAndNeighbors[page], isCurrentAccount = page == 1, showAvatarIndicator = page == 1 && showAvatarIndicator, onClick = if (page == 1) { @@ -332,7 +332,7 @@ private fun AccountIcon( internal fun DefaultRoomListTopBarPreview() = ElementPreview { DefaultRoomListTopBar( title = stringResource(R.string.screen_roomlist_main_space_title), - matrixUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), + currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), showAvatarIndicator = false, areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), @@ -353,7 +353,7 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview { internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview { DefaultRoomListTopBar( title = stringResource(R.string.screen_roomlist_main_space_title), - matrixUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), + currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), showAvatarIndicator = true, areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), @@ -374,7 +374,7 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview { internal fun DefaultRoomListTopBarMultiAccountPreview() = ElementPreview { DefaultRoomListTopBar( title = stringResource(R.string.screen_roomlist_main_space_title), - matrixUserAndNeighbors = aMatrixUserList().take(3).toPersistentList(), + currentUserAndNeighbors = aMatrixUserList().take(3).toPersistentList(), showAvatarIndicator = false, areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 8c693d6bbca..a8f91573c4d 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -75,13 +75,13 @@ class HomePresenterTest { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.matrixUserAndNeighbors.first()).isEqualTo( + assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo( MatrixUser(A_USER_ID, null, null) ) assertThat(initialState.canReportBug).isFalse() skipItems(1) val withUserState = awaitItem() - assertThat(withUserState.matrixUserAndNeighbors.first()).isEqualTo( + assertThat(withUserState.currentUserAndNeighbors.first()).isEqualTo( MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL) ) assertThat(withUserState.showAvatarIndicator).isFalse() @@ -175,7 +175,7 @@ class HomePresenterTest { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.matrixUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId)) + assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId)) // No new state is coming } } From b334f27a3901d01f18891be2df8e49573a087c61 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 17 Sep 2025 10:02:06 +0200 Subject: [PATCH 49/71] Extract code to its own class. --- .../impl/CurrentUserWithNeighborsBuilder.kt | 62 +++++++++++++++++++ .../features/home/impl/HomePresenter.kt | 57 ++--------------- ...=> CurrentUserWithNeighborsBuilderTest.kt} | 34 ++++++---- 3 files changed, 89 insertions(+), 64 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt rename features/home/impl/src/test/kotlin/io/element/android/features/home/impl/{AccountNeighborsTest.kt => CurrentUserWithNeighborsBuilderTest.kt} (84%) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt new file mode 100644 index 00000000000..b73f1e72956 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -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.home.impl + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.sessionstorage.api.SessionData +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList + +class CurrentUserWithNeighborsBuilder { + fun build( + matrixUser: MatrixUser, + sessions: List, + ): ImmutableList { + // Sort by position to always have the same order (not depending on last account usage) + return sessions.sortedBy { it.position } + .map { + if (it.userId == matrixUser.userId.value) { + // Always use the freshest profile for the current user + matrixUser + } else { + // Use the data from the DB + MatrixUser( + userId = UserId(it.userId), + displayName = it.userDisplayName, + avatarUrl = it.userAvatarUrl, + ) + } + } + .let { sessionList -> + // If the list has one item, there is no other session, return the list + when (sessionList.size) { + // Can happen when the user signs out (?) + 0 -> listOf(matrixUser) + 1 -> sessionList + else -> { + // Create a list with extra item at the start and end if necessary to have the current user in the middle + // If the list is [A, B, C, D] and the current user is A we want to return [D, A, B] + // If the current user is B, we want to return [A, B, C] + // If the current user is C, we want to return [B, C, D] + // If the current user is D, we want to return [C, D, A] + val currentUserIndex = sessionList.indexOfFirst { it.userId == matrixUser.userId } + when (currentUserIndex) { + // This can happen when the user signs out. + // In this case, just return a singleton list with the current user. + -1 -> listOf(matrixUser) + 0 -> listOf(sessionList.last()) + sessionList.take(2) + sessionList.lastIndex -> sessionList.takeLast(2) + sessionList.first() + else -> sessionList.slice(currentUserIndex - 1..currentUserIndex + 1) + } + } + } + } + .toPersistentList() + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index bc2a975ba1d..0d9b9662a71 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -7,7 +7,6 @@ package io.element.android.features.home.impl -import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -30,13 +29,9 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.sync.SyncService -import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -54,6 +49,8 @@ class HomePresenter( private val featureFlagService: FeatureFlagService, private val sessionStore: SessionStore, ) : Presenter { + private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() + @Composable override fun present(): HomeState { val coroutineState = rememberCoroutineScope() @@ -69,10 +66,9 @@ class HomePresenter( avatarUrl = user.avatarUrl, ) }, - sessionStore.sessionsFlow() - ) { user, sessions -> - sessions.takeCurrentUserWithNeighbors(user).toPersistentList() - } + sessionStore.sessionsFlow(), + currentUserWithNeighborsBuilder::build, + ) }.collectAsState(initial = persistentListOf(matrixUser)) val isOnline by syncService.isOnline.collectAsState() val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) @@ -128,46 +124,3 @@ class HomePresenter( ) } } - -@VisibleForTesting -internal fun List.takeCurrentUserWithNeighbors(matrixUser: MatrixUser): List { - // Sort by position to always have the same order (not depending on last account usage) - return sortedBy { it.position } - .map { - if (it.userId == matrixUser.userId.value) { - // Always use the freshest profile for the current user - matrixUser - } else { - // Use the data from the DB - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - ) - } - } - .let { sessionList -> - // If the list has one item, there is no other session, return the list - when (sessionList.size) { - // Can happen when the user signs out (?) - 0 -> listOf(matrixUser) - 1 -> sessionList - else -> { - // Create a list with extra item at the start and end if necessary to have the current user in the middle - // If the list is [A, B, C, D] and the current user is A we want to return [D, A, B] - // If the current user is B, we want to return [A, B, C] - // If the current user is C, we want to return [B, C, D] - // If the current user is D, we want to return [C, D, A] - val currentUserIndex = sessionList.indexOfFirst { it.userId == matrixUser.userId } - when (currentUserIndex) { - // This can happen when the user signs out. - // In this case, just return a singleton list with the current user. - -1 -> listOf(matrixUser) - 0 -> listOf(sessionList.last()) + sessionList.take(2) - sessionList.lastIndex -> sessionList.takeLast(2) + sessionList.first() - else -> sessionList.slice(currentUserIndex - 1..currentUserIndex + 1) - } - } - } - } -} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/AccountNeighborsTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt similarity index 84% rename from features/home/impl/src/test/kotlin/io/element/android/features/home/impl/AccountNeighborsTest.kt rename to features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt index 13a27d726ec..a03c0d00657 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/AccountNeighborsTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt @@ -17,17 +17,19 @@ import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.test.aSessionData import org.junit.Test -class AccountNeighborsTest { +class CurrentUserWithNeighborsBuilderTest { @Test - fun `takeCurrentUserWithNeighbors on empty list returns current user`() { + fun `build on empty list returns current user`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser() val list = listOf() - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result).containsExactly(matrixUser) } @Test fun `ensure that account are sorted by position`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser(id = A_USER_ID.value) val list = listOf( aSessionData( @@ -43,7 +45,7 @@ class AccountNeighborsTest { position = 1, ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result.map { it.userId }).containsExactly( A_USER_ID_3, A_USER_ID_2, @@ -53,6 +55,7 @@ class AccountNeighborsTest { @Test fun `if current user is not found, return a singleton with current user`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser(id = A_USER_ID.value) val list = listOf( aSessionData( @@ -62,7 +65,7 @@ class AccountNeighborsTest { sessionId = A_USER_ID_3.value, ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result.map { it.userId }).containsExactly( A_USER_ID, ) @@ -70,13 +73,14 @@ class AccountNeighborsTest { @Test fun `one account, will return a singleton`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser(id = A_USER_ID.value) val list = listOf( aSessionData( sessionId = A_USER_ID.value, ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result.map { it.userId }).containsExactly( A_USER_ID, ) @@ -84,6 +88,7 @@ class AccountNeighborsTest { @Test fun `two accounts, first is current, will return 3 items`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser(id = A_USER_ID.value) val list = listOf( aSessionData( @@ -93,7 +98,7 @@ class AccountNeighborsTest { sessionId = A_USER_ID_2.value, ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result.map { it.userId }).containsExactly( A_USER_ID_2, A_USER_ID, @@ -103,6 +108,7 @@ class AccountNeighborsTest { @Test fun `two accounts, second is current, will return 3 items`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser(id = A_USER_ID_2.value) val list = listOf( aSessionData( @@ -112,7 +118,7 @@ class AccountNeighborsTest { sessionId = A_USER_ID_2.value, ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result.map { it.userId }).containsExactly( A_USER_ID, A_USER_ID_2, @@ -122,6 +128,7 @@ class AccountNeighborsTest { @Test fun `three accounts, first is current, will return last current and next`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser(id = A_USER_ID.value) val list = listOf( aSessionData( @@ -134,7 +141,7 @@ class AccountNeighborsTest { sessionId = A_USER_ID_3.value, ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result.map { it.userId }).containsExactly( A_USER_ID_3, A_USER_ID, @@ -144,6 +151,7 @@ class AccountNeighborsTest { @Test fun `three accounts, second is current, will return first current and last`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser(id = A_USER_ID_2.value) val list = listOf( aSessionData( @@ -156,7 +164,7 @@ class AccountNeighborsTest { sessionId = A_USER_ID_3.value, ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result.map { it.userId }).containsExactly( A_USER_ID, A_USER_ID_2, @@ -166,6 +174,7 @@ class AccountNeighborsTest { @Test fun `three accounts, current is last, will return middle, current and first`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser(id = A_USER_ID_3.value) val list = listOf( aSessionData( @@ -178,7 +187,7 @@ class AccountNeighborsTest { sessionId = A_USER_ID.value, ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result.map { it.userId }).containsExactly( A_USER_ID, A_USER_ID_2, @@ -188,6 +197,7 @@ class AccountNeighborsTest { @Test fun `one account, will return data from matrix user and not from db`() { + val sut = CurrentUserWithNeighborsBuilder() val matrixUser = aMatrixUser( id = A_USER_ID.value, displayName = "Bob", @@ -200,7 +210,7 @@ class AccountNeighborsTest { userAvatarUrl = "outdatedAvatarUrl", ), ) - val result = list.takeCurrentUserWithNeighbors(matrixUser) + val result = sut.build(matrixUser, list) assertThat(result).containsExactly( MatrixUser( userId = A_USER_ID, From 2ec6b2c3d426f359261cae7d30e212f494818bb9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 17 Sep 2025 10:08:11 +0200 Subject: [PATCH 50/71] Add comment to clarify the code. --- .../features/home/impl/CurrentUserWithNeighborsBuilder.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt index b73f1e72956..d19222d44fa 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -14,6 +14,11 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList class CurrentUserWithNeighborsBuilder { + /** + * Build a list of [MatrixUser] containing the current user. If there are other sessions, the list + * will contain 3 users, with the current user in the middle. + * If there is only one other session, the list will contain twice the other user, to allow cycling. + */ fun build( matrixUser: MatrixUser, sessions: List, @@ -45,6 +50,8 @@ class CurrentUserWithNeighborsBuilder { // If the current user is B, we want to return [A, B, C] // If the current user is C, we want to return [B, C, D] // If the current user is D, we want to return [C, D, A] + // Special case: if there are only two users, we want to return [B, A, B] or [A, B, A] to allows cycling + // between the two users. val currentUserIndex = sessionList.indexOfFirst { it.userId == matrixUser.userId } when (currentUserIndex) { // This can happen when the user signs out. From 6baf08b7d8c14cfb70381aeb11358bff81341f23 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 17 Sep 2025 10:56:06 +0200 Subject: [PATCH 51/71] Init current user profile with what we now have in the database. It allows having the cached data (user display name and avatar) when starting the application when no network is available. --- .../android/libraries/matrix/impl/RustMatrixClient.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index e193d8f37de..dd2023776b1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -235,7 +235,6 @@ class RustMatrixClient( private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( userId = sessionId, - // TODO cache for displayName? displayName = null, avatarUrl = null, ) @@ -264,6 +263,16 @@ class RustMatrixClient( // Start notification settings notificationSettingsService.start() + // Update the user profile in the session store if needed + sessionStore.getSession(sessionId.value)?.let { sessionData -> + _userProfile.emit( + MatrixUser( + userId = sessionId, + displayName = sessionData.userDisplayName, + avatarUrl = sessionData.userAvatarUrl, + ) + ) + } // Force a refresh of the profile getUserProfile() } From a84e2ff1f1306c8710d26a91511d17220dbad055 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 17 Sep 2025 11:15:31 +0200 Subject: [PATCH 52/71] Let the RustMatrixClient update the profile in the session database --- .../features/home/impl/HomePresenter.kt | 11 +---- .../features/home/impl/HomePresenterTest.kt | 17 ------- .../libraries/matrix/impl/RustMatrixClient.kt | 10 +++- .../matrix/impl/RustMatrixClientTest.kt | 48 ++++++++++++++++++- .../impl/fixtures/fakes/FakeFfiClient.kt | 3 +- 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 0d9b9662a71..653a7134f32 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -33,7 +33,6 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @Inject @@ -57,15 +56,7 @@ class HomePresenter( val matrixUser by client.userProfile.collectAsState() val currentUserAndNeighbors by remember { combine( - client.userProfile.onEach { user -> - // Ensure that the profile is always up to date in our - // session storage when it changes - sessionStore.updateUserProfile( - sessionId = user.userId.value, - displayName = user.displayName, - avatarUrl = user.avatarUrl, - ) - }, + client.userProfile, sessionStore.sessionsFlow(), currentUserWithNeighborsBuilder::build, ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index a8f91573c4d..d551d2cc541 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -37,8 +37,6 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.MutablePresenter import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -56,7 +54,6 @@ class HomePresenterTest { userAvatarUrl = null, ) matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL))) - val updateUserProfileResult = lambdaRecorder { _, _, _ -> } val presenter = createHomePresenter( client = matrixClient, rageshakeFeatureAvailability = { flowOf(false) }, @@ -68,7 +65,6 @@ class HomePresenterTest { userAvatarUrl = null, ) ), - updateUserProfileResult = updateUserProfileResult, ), ) moleculeFlow(RecompositionMode.Immediate) { @@ -87,19 +83,6 @@ class HomePresenterTest { assertThat(withUserState.showAvatarIndicator).isFalse() assertThat(withUserState.isSpaceFeatureEnabled).isFalse() assertThat(withUserState.showNavigationBar).isFalse() - updateUserProfileResult.assertions().isCalledExactly(2) - .withSequence( - listOf( - value(matrixClient.sessionId.value), - value(null), - value(null), - ), - listOf( - value(matrixClient.sessionId.value), - value(A_USER_NAME), - value(AN_AVATAR_URL), - ), - ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index dd2023776b1..c3d8b426669 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -409,7 +409,15 @@ class RustMatrixClient( } override suspend fun getUserProfile(): Result = getProfile(sessionId) - .onSuccess { _userProfile.tryEmit(it) } + .onSuccess { matrixUser -> + _userProfile.emit(matrixUser) + // Also update our session storage + sessionStore.updateUserProfile( + sessionId = sessionId.value, + displayName = matrixUser.displayName, + avatarUrl = matrixUser.avatarUrl, + ) + } override suspend fun searchUsers(searchTerm: String, limit: Long): Result = withContext(sessionDispatcher) { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index 99f165eafb1..6eed2d09672 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -5,6 +5,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat @@ -12,17 +14,24 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory +import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.UserProfile import java.io.File class RustMatrixClientTest { @@ -51,9 +60,46 @@ class RustMatrixClientTest { client.destroy() } + @Test + fun `retrieving the UserProfile updates the database`() = runTest { + val updateUserProfileResult = lambdaRecorder { _, _, _ -> } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = null, + userAvatarUrl = null, + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> + UserProfile( + userId = userId, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + ) + }, + ), + sessionStore = sessionStore, + ) + advanceUntilIdle() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + ) + client.destroy() + } + private fun TestScope.createRustMatrixClient( client: Client = FakeFfiClient(), - sessionStore: SessionStore = InMemorySessionStore(), + sessionStore: SessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), ) = RustMatrixClient( innerClient = client, baseDirectory = File(""), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index 638e0a2f70c..e149fb3db38 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -41,6 +41,7 @@ class FakeFfiClient( private val session: Session = aRustSession(), private val clearCachesResult: () -> Unit = { lambdaError() }, private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() }, + private val getProfileResult: (String) -> UserProfile = { UserProfile(userId = userId, displayName = null, avatarUrl = null) }, private val closeResult: () -> Unit = {}, ) : Client(NoPointer) { override fun userId(): String = userId @@ -76,7 +77,7 @@ class FakeFfiClient( } override suspend fun getProfile(userId: String): UserProfile { - return UserProfile(userId = userId, displayName = null, avatarUrl = null) + return getProfileResult(userId) } override fun close() = closeResult() } From e942e906a0716d8de4bac9585f0b1eb4b5bb7afb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 17 Sep 2025 11:49:13 +0200 Subject: [PATCH 53/71] Fix test. --- .../libraries/matrix/impl/RustMatrixClientFactoryTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 6b39f7b2487..aa1cbd59639 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -38,7 +38,9 @@ class RustMatrixClientFactoryTest { fun TestScope.createRustMatrixClientFactory( baseDirectory: File = File("/base"), cacheDirectory: File = File("/cache"), - sessionStore: SessionStore = InMemorySessionStore(), + sessionStore: SessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), ) = RustMatrixClientFactory( baseDirectory = baseDirectory, cacheDirectory = cacheDirectory, From 823cd9795a26c1b21f6a2d9659797b8ece35a835 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 18 Sep 2025 09:44:02 +0200 Subject: [PATCH 54/71] When logging out from Pin code screen, logout from all the sessions. tom --- .../impl/unlock/PinUnlockPresenter.kt | 2 +- .../features/logout/api/LogoutUseCase.kt | 10 +- features/logout/impl/build.gradle.kts | 1 + .../logout/impl/DefaultLogoutUseCase.kt | 31 +++-- .../logout/impl/DefaultLogoutUseCaseTest.kt | 120 ++++++++++++++++++ .../features/logout/test/FakeLogoutUseCase.kt | 2 +- 6 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCaseTest.kt diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt index cf0f864f666..fc2e61d4047 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt @@ -174,7 +174,7 @@ class PinUnlockPresenter( private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch { suspend { - logoutUseCase.logout(ignoreSdkError = true) + logoutUseCase.logoutAll(ignoreSdkError = true) }.runCatchingUpdatingState(signOutAction) } } diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt index 6f265ec88c5..3d980fe44b0 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt @@ -8,16 +8,12 @@ package io.element.android.features.logout.api /** - * Used to trigger a log out of the current user from any part of the app. + * Used to trigger a log out of the current user(s) from any part of the app. */ interface LogoutUseCase { /** - * Log out the current user and then perform any needed cleanup tasks. + * Log out the current user(s) and then perform any needed cleanup tasks. * @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway. */ - suspend fun logout(ignoreSdkError: Boolean) - - interface Factory { - fun create(sessionId: String): LogoutUseCase - } + suspend fun logoutAll(ignoreSdkError: Boolean) } diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts index 09fdc1bd965..e6a833e98f7 100644 --- a/features/logout/impl/build.gradle.kts +++ b/features/logout/impl/build.gradle.kts @@ -45,5 +45,6 @@ dependencies { testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.tests.testutils) } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt index 06a79a8d2dd..52e295ba3eb 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt @@ -12,22 +12,31 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import io.element.android.features.logout.api.LogoutUseCase import io.element.android.libraries.matrix.api.MatrixClientProvider -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore +import timber.log.Timber @ContributesBinding(AppScope::class) @Inject class DefaultLogoutUseCase( - private val authenticationService: MatrixAuthenticationService, + private val sessionStore: SessionStore, private val matrixClientProvider: MatrixClientProvider, ) : LogoutUseCase { - override suspend fun logout(ignoreSdkError: Boolean) { - val currentSession = authenticationService.getLatestSessionId() - if (currentSession != null) { - matrixClientProvider.getOrRestore(currentSession) - .getOrThrow() - .logout(userInitiated = true, ignoreSdkError = true) - } else { - error("No session to sign out") - } + override suspend fun logoutAll(ignoreSdkError: Boolean) { + sessionStore.getAllSessions() + .map { sessionData -> + SessionId(sessionData.userId) + } + .forEach { sessionId -> + Timber.d("Logging out sessionId: $sessionId") + matrixClientProvider.getOrRestore(sessionId).fold( + onSuccess = { client -> + client.logout(userInitiated = true, ignoreSdkError = ignoreSdkError) + }, + onFailure = { error -> + Timber.e(error, "Failed to get or restore MatrixClient for sessionId: $sessionId") + } + ) + } } } diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCaseTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCaseTest.kt new file mode 100644 index 00000000000..a17e7285de4 --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCaseTest.kt @@ -0,0 +1,120 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.logout.impl + +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultLogoutUseCaseTest { + @Test + fun `test logout from one session`() = runTest { + val logoutLambda1 = lambdaRecorder { _, _ -> } + val client1 = FakeMatrixClient(A_USER_ID).apply { + logoutLambda = logoutLambda1 + } + val sut = DefaultLogoutUseCase( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.success(client1) + else -> error("Unexpected sessionId") + } + } + ), + ) + sut.logoutAll(ignoreSdkError = true) + logoutLambda1.assertions().isCalledOnce().with(value(true), value(true)) + } + + @Test + fun `test logout from several sessions`() = runTest { + val logoutLambda1 = lambdaRecorder { _, _ -> } + val logoutLambda2 = lambdaRecorder { _, _ -> } + val client1 = FakeMatrixClient(A_USER_ID).apply { + logoutLambda = logoutLambda1 + } + val client2 = FakeMatrixClient(A_USER_ID_2).apply { + logoutLambda = logoutLambda2 + } + val sut = DefaultLogoutUseCase( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID.value), + aSessionData(sessionId = A_USER_ID_2.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.success(client1) + A_USER_ID_2 -> Result.success(client2) + else -> error("Unexpected sessionId") + } + } + ), + ) + sut.logoutAll(ignoreSdkError = true) + logoutLambda1.assertions().isCalledOnce().with(value(true), value(true)) + logoutLambda2.assertions().isCalledOnce().with(value(true), value(true)) + } + + @Test + fun `test logout session not found is ignored`() = runTest { + val sut = DefaultLogoutUseCase( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.failure(Exception("Session not found")) + else -> error("Unexpected sessionId") + } + } + ), + ) + sut.logoutAll(ignoreSdkError = true) + // No error + } + + @Test + fun `test logout no sessions`() = runTest { + val sut = DefaultLogoutUseCase( + sessionStore = InMemorySessionStore( + initialList = emptyList() + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { sessionId -> + when (sessionId) { + else -> error("Unexpected sessionId") + } + } + ), + ) + sut.logoutAll(ignoreSdkError = true) + // No error + } +} diff --git a/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt index e71266b596b..dd3ded4ef90 100644 --- a/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt +++ b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt @@ -14,7 +14,7 @@ import io.element.android.tests.testutils.simulateLongTask class FakeLogoutUseCase( var logoutLambda: (Boolean) -> Unit = { lambdaError() } ) : LogoutUseCase { - override suspend fun logout(ignoreSdkError: Boolean) = simulateLongTask { + override suspend fun logoutAll(ignoreSdkError: Boolean) = simulateLongTask { logoutLambda(ignoreSdkError) } } From 097fc98fed48344d5be747c7371eee6155b090a8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 18 Sep 2025 10:48:39 +0200 Subject: [PATCH 55/71] Make PushData.clientSecret mandatory. Also do not restore the last session as a fallback, it can lead to error in a multi account context, or even when a ghost pusher send a Push. --- .../push/impl/push/DefaultPushHandler.kt | 27 ++------ .../push/impl/push/DefaultPushHandlerTest.kt | 62 +------------------ .../libraries/pushproviders/api/PushData.kt | 2 +- .../firebase/PushDataFirebase.kt | 3 +- .../firebase/FirebasePushParserTest.kt | 6 ++ 5 files changed, 16 insertions(+), 84 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 9dafe935692..6f6d4b0a0ac 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -16,7 +16,6 @@ import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.annotations.AppCoroutineScope -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.exception.NotificationResolverException import io.element.android.libraries.push.impl.history.PushHistoryService import io.element.android.libraries.push.impl.history.onDiagnosticPush @@ -58,7 +57,6 @@ class DefaultPushHandler( private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, private val buildMeta: BuildMeta, - private val matrixAuthenticationService: MatrixAuthenticationService, private val diagnosticPushHandler: DiagnosticPushHandler, private val elementCallEntryPoint: ElementCallEntryPoint, private val notificationChannels: NotificationChannels, @@ -241,32 +239,15 @@ class DefaultPushHandler( } else { Timber.tag(loggerTag.value).d("## handleInternal()") } - val clientSecret = pushData.clientSecret - // clientSecret should not be null. If this happens, restore default session - var reason = if (clientSecret == null) "No client secret" else "" - val userId = clientSecret?.let { - // Get userId from client secret - pushClientSecret.getUserIdFromSecret(clientSecret).also { - if (it == null) { - reason = "Unable to get userId from client secret" - } - } - } - ?: run { - matrixAuthenticationService.getLatestSessionId().also { - if (it == null) { - if (reason.isNotEmpty()) reason += " - " - reason += "Unable to get latest sessionId" - } - } - } + // Get userId from client secret + val userId = pushClientSecret.getUserIdFromSecret(pushData.clientSecret) if (userId == null) { - Timber.w("Unable to get a session") + Timber.w("Unable to get userId from client secret") pushHistoryService.onUnableToRetrieveSession( providerInfo = providerInfo, eventId = pushData.eventId, roomId = pushData.roomId, - reason = reason, + reason = "Unable to get userId from client secret", ) return } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index a070156d057..be1a1f403a5 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -14,7 +14,6 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallType import io.element.android.features.call.test.FakeElementCallEntryPoint import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -28,7 +27,6 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SECRET import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.push.impl.history.FakePushHistoryService import io.element.android.libraries.push.impl.history.PushHistoryService @@ -181,7 +179,7 @@ class DefaultPushHandlerTest { } @Test - fun `when PushData is received, but client secret is not known, fallback the latest session`() = + fun `when PushData is received, but client secret is not known, nothing happen`() = runTest { val aNotifiableMessageEvent = aNotifiableMessageEvent() val notifiableEventResult = @@ -207,58 +205,6 @@ class DefaultPushHandlerTest { pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { null } ), - matrixAuthenticationService = FakeMatrixAuthenticationService().apply { - getLatestSessionIdLambda = { A_USER_ID } - }, - incrementPushCounterResult = incrementPushCounterResult, - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - incrementPushCounterResult.assertions() - .isCalledOnce() - notifiableEventResult.assertions() - .isCalledOnce() - .with(value(A_USER_ID), any()) - onNotifiableEventsReceived.assertions() - .isCalledOnce() - .with(value(listOf(aNotifiableMessageEvent))) - onPushReceivedResult.assertions() - .isCalledOnce() - } - - @Test - fun `when PushData is received, but client secret is not known, and there is no latest session, nothing happen`() = - runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val incrementPushCounterResult = lambdaRecorder {} - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { null } - ), - matrixAuthenticationService = FakeMatrixAuthenticationService().apply { - getLatestSessionIdLambda = { null } - }, incrementPushCounterResult = incrementPushCounterResult, pushHistoryService = pushHistoryService, ) @@ -655,8 +601,8 @@ class DefaultPushHandlerTest { var receivedFallbackEvent = false val onPushReceivedResult = lambdaRecorder { _, _, _, _, isResolved, _, comment -> - receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}" - } + receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}" + } val pushHistoryService = FakePushHistoryService( onPushReceivedResult = onPushReceivedResult, ) @@ -694,7 +640,6 @@ class DefaultPushHandlerTest { userPushStore: UserPushStore = FakeUserPushStore(), pushClientSecret: PushClientSecret = FakePushClientSecret(), buildMeta: BuildMeta = aBuildMeta(), - matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(), notificationChannels: FakeNotificationChannels = FakeNotificationChannels(), @@ -712,7 +657,6 @@ class DefaultPushHandlerTest { userPushStoreFactory = FakeUserPushStoreFactory { userPushStore }, pushClientSecret = pushClientSecret, buildMeta = buildMeta, - matrixAuthenticationService = matrixAuthenticationService, diagnosticPushHandler = diagnosticPushHandler, elementCallEntryPoint = elementCallEntryPoint, notificationChannels = notificationChannels, diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushData.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushData.kt index e08ab1c18f8..6000f74a950 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushData.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushData.kt @@ -22,5 +22,5 @@ data class PushData( val eventId: EventId, val roomId: RoomId, val unread: Int?, - val clientSecret: String?, + val clientSecret: String, ) diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt index 7bc1515332a..fb33ab9c1f7 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt @@ -34,10 +34,11 @@ data class PushDataFirebase( fun PushDataFirebase.toPushData(): PushData? { val safeEventId = eventId?.let(::EventId) ?: return null val safeRoomId = roomId?.let(::RoomId) ?: return null + val safeClientSecret = clientSecret ?: return null return PushData( eventId = safeEventId, roomId = safeRoomId, unread = unread, - clientSecret = clientSecret, + clientSecret = safeClientSecret, ) } diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParserTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParserTest.kt index 71848fc4df2..49ed5bc6d4e 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParserTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParserTest.kt @@ -59,6 +59,12 @@ class FirebasePushParserTest { assertThrowsInDebug { pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", "")) } } + @Test + fun `test empty client secret`() { + val pushParser = FirebasePushParser() + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("cs", null))).isNull() + } + @Test fun `test invalid eventId`() { val pushParser = FirebasePushParser() From 7059c236b9e3bf09c0111b550aa02e9541ca8e53 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 18 Sep 2025 15:21:05 +0200 Subject: [PATCH 56/71] Change test in RustMatrixAuthenticationServiceTest --- .../matrix/impl/FakeClientBuilderProvider.kt | 6 ++-- .../impl/RustMatrixClientFactoryTest.kt | 3 +- .../RustMatrixAuthenticationServiceTest.kt | 30 ++++++++++++++----- .../impl/fixtures/fakes/FakeFfiClient.kt | 8 +++++ .../fixtures/fakes/FakeFfiClientBuilder.kt | 6 ++-- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeClientBuilderProvider.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeClientBuilderProvider.kt index 3406b6686ad..89e0574fdfc 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeClientBuilderProvider.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeClientBuilderProvider.kt @@ -10,8 +10,10 @@ package io.element.android.libraries.matrix.impl import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder import org.matrix.rustcomponents.sdk.ClientBuilder -class FakeClientBuilderProvider : ClientBuilderProvider { +class FakeClientBuilderProvider( + private val provideResult: () -> ClientBuilder = { FakeFfiClientBuilder() } +) : ClientBuilderProvider { override fun provide(): ClientBuilder { - return FakeFfiClientBuilder() + return provideResult() } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index aa1cbd59639..045b5d7772c 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -41,6 +41,7 @@ fun TestScope.createRustMatrixClientFactory( sessionStore: SessionStore = InMemorySessionStore( updateUserProfileResult = { _, _, _ -> }, ), + clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), ) = RustMatrixClientFactory( baseDirectory = baseDirectory, cacheDirectory = cacheDirectory, @@ -54,5 +55,5 @@ fun TestScope.createRustMatrixClientFactory( analyticsService = FakeAnalyticsService(), featureFlagService = FakeFeatureFlagService(), timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), - clientBuilderProvider = FakeClientBuilderProvider(), + clientBuilderProvider = clientBuilderProvider, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index 3ec47488b73..1cb5db91f6f 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -8,14 +8,17 @@ package io.element.android.libraries.matrix.impl.auth import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.ClientBuilderProvider +import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore -import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -24,18 +27,28 @@ import java.io.File class RustMatrixAuthenticationServiceTest { @Test - fun `getLatestSessionId should return the value from the store`() = runTest { - val sessionStore = InMemorySessionStore() + fun `setHomeserver is successful`() = runTest { val sut = createRustMatrixAuthenticationService( - sessionStore = sessionStore, + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + homeserverLoginDetailsResult = { + FakeFfiHomeserverLoginDetails() + } + ) + } + ) + } + ), ) - assertThat(sut.getLatestSessionId()).isNull() - sessionStore.addSession(aSessionData(sessionId = "@alice:server.org")) - assertThat(sut.getLatestSessionId()).isEqualTo(SessionId("@alice:server.org")) + assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() } private fun TestScope.createRustMatrixAuthenticationService( sessionStore: SessionStore = InMemorySessionStore(), + clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), ): RustMatrixAuthenticationService { val baseDirectory = File("/base") val cacheDirectory = File("/cache") @@ -43,6 +56,7 @@ class RustMatrixAuthenticationServiceTest { baseDirectory = baseDirectory, cacheDirectory = cacheDirectory, sessionStore = sessionStore, + clientBuilderProvider = clientBuilderProvider, ) return RustMatrixAuthenticationService( sessionPathsFactory = SessionPathsFactory(baseDirectory, cacheDirectory), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index e149fb3db38..3f53d9d5e20 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -15,6 +15,7 @@ import io.element.android.tests.testutils.simulateLongTask import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.NoPointer import org.matrix.rustcomponents.sdk.NotificationClient @@ -42,6 +43,7 @@ class FakeFfiClient( private val clearCachesResult: () -> Unit = { lambdaError() }, private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() }, private val getProfileResult: (String) -> UserProfile = { UserProfile(userId = userId, displayName = null, avatarUrl = null) }, + private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, private val closeResult: () -> Unit = {}, ) : Client(NoPointer) { override fun userId(): String = userId @@ -72,6 +74,7 @@ class FakeFfiClient( override suspend fun ignoredUsers(): List { return emptyList() } + override fun subscribeToIgnoredUsers(listener: IgnoredUsersListener): TaskHandle { return FakeFfiTaskHandle() } @@ -79,5 +82,10 @@ class FakeFfiClient( override suspend fun getProfile(userId: String): UserProfile { return getProfileResult(userId) } + + override suspend fun homeserverLoginDetails(): HomeserverLoginDetails { + return homeserverLoginDetailsResult() + } + override fun close() = closeResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt index 6e0c73b3508..73417a333a0 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt @@ -17,7 +17,9 @@ import uniffi.matrix_sdk.BackupDownloadStrategy import uniffi.matrix_sdk_crypto.CollectStrategy import uniffi.matrix_sdk_crypto.DecryptionSettings -class FakeFfiClientBuilder : ClientBuilder(NoPointer) { +class FakeFfiClientBuilder( + val buildResult: () -> Client = { FakeFfiClient(withUtdHook = {}) } +) : ClientBuilder(NoPointer) { override fun addRootCertificates(certificates: List) = this override fun autoEnableBackups(autoEnableBackups: Boolean) = this override fun autoEnableCrossSigning(autoEnableCrossSigning: Boolean) = this @@ -42,6 +44,6 @@ class FakeFfiClientBuilder : ClientBuilder(NoPointer) { override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this override suspend fun build(): Client { - return FakeFfiClient(withUtdHook = {}) + return buildResult() } } From f589dd282db69729801af81595e60f3ddb255f01 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 18 Sep 2025 15:27:20 +0200 Subject: [PATCH 57/71] Do not use MatrixAuthenticationService in RootFlowNode, only use SessionStore --- .../kotlin/io/element/android/appnav/RootFlowNode.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 634948d7e2d..ff1dfc2feda 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -50,7 +50,6 @@ import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -70,7 +69,6 @@ import timber.log.Timber class RootFlowNode( @Assisted val buildContext: BuildContext, @Assisted plugins: List, - private val authenticationService: MatrixAuthenticationService, private val accountProviderAccessControl: AccountProviderAccessControl, private val navStateFlowFactory: RootNavStateFlowFactory, private val matrixSessionCache: MatrixSessionCache, @@ -162,7 +160,7 @@ class RootFlowNode( onSuccess: (SessionId) -> Unit, onFailure: () -> Unit ) { - val latestSessionId = authenticationService.getLatestSessionId() + val latestSessionId = sessionStore.getLatestSessionId() if (latestSessionId == null) { onFailure() return @@ -368,7 +366,7 @@ class RootFlowNode( private suspend fun onIncomingShare(intent: Intent) { // Is there a session already? - val latestSessionId = authenticationService.getLatestSessionId() + val latestSessionId = sessionStore.getLatestSessionId() if (latestSessionId == null) { // No session, open login switchToNotLoggedInFlow(null) @@ -394,7 +392,7 @@ class RootFlowNode( private suspend fun navigateTo(permalinkData: PermalinkData) { Timber.d("Navigating to $permalinkData") // Is there a session already? - val latestSessionId = authenticationService.getLatestSessionId() + val latestSessionId = sessionStore.getLatestSessionId() if (latestSessionId == null) { // No session, open login switchToNotLoggedInFlow(null) @@ -466,3 +464,5 @@ class RootFlowNode( .attachSession() } } + +private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId) From 3fd97c1ce1a44faf28e8a122ffeda24cb01fcbf9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 18 Sep 2025 15:31:07 +0200 Subject: [PATCH 58/71] Remove MatrixAuthenticationService.getLatestSessionId() --- .../libraries/matrix/api/auth/MatrixAuthenticationService.kt | 2 -- .../matrix/impl/auth/RustMatrixAuthenticationService.kt | 4 ---- .../matrix/test/auth/FakeMatrixAuthenticationService.kt | 4 ---- 3 files changed, 10 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index 51778211cc6..38777944ee0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -16,8 +16,6 @@ import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.StateFlow interface MatrixAuthenticationService { - suspend fun getLatestSessionId(): SessionId? - /** * Restore a session from a [sessionId]. * Do not restore anything it the access token is not valid anymore. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index d07f680b1f3..88c86a43d62 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -82,10 +82,6 @@ class RustMatrixAuthenticationService( .also { sessionPaths = it } } - override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { - sessionStore.getLatestSession()?.userId?.let { SessionId(it) } - } - override suspend fun restoreSession(sessionId: SessionId): Result = withContext(coroutineDispatchers.io) { runCatchingExceptions { val sessionData = sessionStore.getSession(sessionId.value) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt index 79f9942628d..f1554df1d0e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -41,10 +41,6 @@ class FakeMatrixAuthenticationService( private var matrixClient: MatrixClient? = null private var onAuthenticationListener: ((MatrixClient) -> Unit)? = null - var getLatestSessionIdLambda: (() -> SessionId?) = { null } - - override suspend fun getLatestSessionId(): SessionId? = getLatestSessionIdLambda() - override suspend fun restoreSession(sessionId: SessionId): Result { matrixClientResult?.let { return it.invoke(sessionId) From 81e79119f1696df9da2b813187fa398e91972bb3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 19 Sep 2025 14:14:23 +0200 Subject: [PATCH 59/71] Fix compilation issue after merging develop --- .../preferences/impl/DefaultPreferencesEntryPointTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt index 807ff3a5c67..9e1bd703762 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -20,7 +20,6 @@ import io.element.android.features.logout.api.LogoutEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint import io.element.android.tests.testutils.lambda.lambdaError @@ -64,10 +63,11 @@ class DefaultPreferencesEntryPointTest { ) } val callback = object : PreferencesEntryPoint.Callback { + override fun onAddAccount() = lambdaError() override fun onOpenBugReport() = lambdaError() override fun onSecureBackupClick() = lambdaError() override fun onOpenRoomNotificationSettings(roomId: RoomId) = lambdaError() - override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) = lambdaError() + override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError() } val params = PreferencesEntryPoint.Params( initialElement = PreferencesEntryPoint.InitialTarget.NotificationSettings, From b8c441f3c89c0bb3e30967ddf0d453de8aaa48ec Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 19 Sep 2025 14:20:47 +0200 Subject: [PATCH 60/71] Add test on DefaultAccountSelectEntryPoint --- libraries/accountselect/impl/build.gradle.kts | 8 +--- .../impl/AccountSelectPresenterTest.kt | 12 ++--- .../DefaultAccountSelectEntryPointTest.kt | 44 +++++++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPointTest.kt diff --git a/libraries/accountselect/impl/build.gradle.kts b/libraries/accountselect/impl/build.gradle.kts index 88a82e56930..ea1fbd52add 100644 --- a/libraries/accountselect/impl/build.gradle.kts +++ b/libraries/accountselect/impl/build.gradle.kts @@ -1,4 +1,5 @@ import extension.setupDependencyInjection +import extension.testCommonDependencies /* * Copyright 2025 New Vector Ltd. @@ -28,12 +29,7 @@ dependencies { implementation(projects.libraries.uiStrings) api(projects.libraries.accountselect.api) - testImplementation(libs.test.junit) - testImplementation(libs.coroutines.test) - testImplementation(libs.molecule.runtime) - testImplementation(libs.test.truth) - testImplementation(libs.test.turbine) + testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.tests.testutils) } diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt index 399402342bc..27a8d7d9cfd 100644 --- a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -69,10 +69,10 @@ class AccountSelectPresenterTest { ) } } - - private fun createAccountSelectPresenter( - sessionStore: SessionStore = InMemorySessionStore(), - ) = AccountSelectPresenter( - sessionStore = sessionStore, - ) } + +internal fun createAccountSelectPresenter( + sessionStore: SessionStore = InMemorySessionStore(), +) = AccountSelectPresenter( + sessionStore = sessionStore, +) diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPointTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPointTest.kt new file mode 100644 index 00000000000..d61dcc89ba9 --- /dev/null +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPointTest.kt @@ -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.libraries.accountselect.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultAccountSelectEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultAccountSelectEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + AccountSelectNode( + buildContext = buildContext, + plugins = plugins, + presenter = createAccountSelectPresenter(), + ) + } + val callback = object : AccountSelectEntryPoint.Callback { + override fun onSelectAccount(sessionId: SessionId) = lambdaError() + override fun onCancel() = lambdaError() + } + val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null)) + .callback(callback) + .build() + assertThat(result).isInstanceOf(AccountSelectNode::class.java) + assertThat(result.plugins).contains(callback) + } +} From b3f3f521034027de56d7a2b1c9501e04ba91661f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 19 Sep 2025 14:27:25 +0200 Subject: [PATCH 61/71] Fix compilation issue after merging develop --- .../impl/history/PushHistoryPresenter.kt | 2 +- .../history/DefaultPushHistoryEntryPointTest.kt | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt index 912cbc45c99..44e8c848717 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt @@ -38,7 +38,7 @@ class PushHistoryPresenter( matrixClient: MatrixClient, ) : Presenter { @AssistedFactory - interface Factory { + fun interface Factory { fun create(pushHistoryNavigator: PushHistoryNavigator): PushHistoryPresenter } diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPointTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPointTest.kt index 3604622f5c0..858956488cd 100644 --- a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPointTest.kt +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPointTest.kt @@ -12,7 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint import io.element.android.services.analytics.test.FakeScreenTracker @@ -32,15 +32,21 @@ class DefaultPushHistoryEntryPointTest { PushHistoryNode( buildContext = buildContext, plugins = plugins, - presenter = PushHistoryPresenter( - pushService = FakePushService(), - ), + presenterFactory = { + PushHistoryPresenter( + pushHistoryNavigator = object : PushHistoryNavigator { + override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError() + }, + pushService = FakePushService(), + matrixClient = FakeMatrixClient(), + ) + }, screenTracker = FakeScreenTracker(), ) } val callback = object : PushHistoryEntryPoint.Callback { override fun onDone() = lambdaError() - override fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) = lambdaError() + override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError() } val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null)) .callback(callback) From 9c698ff8152aceb5fd2b8b5ab5f609d28de64d24 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 19 Sep 2025 14:38:01 +0200 Subject: [PATCH 62/71] Introduce LoggedInAccountSwitcherNode, to improve animation when switching between accounts. --- .../appnav/LoggedInAccountSwitcherNode.kt | 160 ++++++++++++++++++ .../io/element/android/appnav/RootFlowNode.kt | 66 ++------ .../android/appnav/store/SessionStoreExt.kt | 13 ++ 3 files changed, 183 insertions(+), 56 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/store/SessionStoreExt.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt new file mode 100644 index 00000000000..bbbe12acbe0 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt @@ -0,0 +1,160 @@ +/* + * 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 android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +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.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.Inject +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.di.MatrixSessionCache +import io.element.android.appnav.root.RootNavStateFlowFactory +import io.element.android.appnav.store.getLatestSessionId +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.waitForChildAttached +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(AppScope::class) +@Inject +class LoggedInAccountSwitcherNode( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List, + private val navStateFlowFactory: RootNavStateFlowFactory, + private val sessionStore: SessionStore, + private val matrixSessionCache: MatrixSessionCache, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + interface Callback : Plugin { + fun onOpenBugReport() + fun onAddAccount() + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class LoggedInFlow( + val sessionId: SessionId, + val navId: Int + ) : NavTarget + } + + override fun onBuilt() { + super.onBuilt() + observeNavState() + } + + private fun observeNavState() { + navStateFlowFactory.create(buildContext.savedStateMap) + .distinctUntilChanged() + .onEach { navState -> + Timber.v("navState=$navState") + if (navState.loggedInState is LoggedInState.LoggedIn && navState.loggedInState.isTokenValid) { + tryToRestoreLatestSession( + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, + onFailure = { }, + ) + } + } + .launchIn(lifecycleScope) + } + + private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { + backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) + } + + private suspend fun tryToRestoreLatestSession( + onSuccess: (SessionId) -> Unit, + onFailure: () -> Unit + ) { + val latestSessionId = sessionStore.getLatestSessionId() + if (latestSessionId == null) { + onFailure() + return + } + restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess) + } + + private suspend fun restoreSessionIfNeeded( + sessionId: SessionId, + onFailure: () -> Unit, + onSuccess: (SessionId) -> Unit, + ) { + matrixSessionCache.getOrRestore(sessionId) + .onSuccess { + Timber.v("Succeed to restore session $sessionId") + onSuccess(sessionId) + } + .onFailure { + Timber.e(it, "Failed to restore session $sessionId") + onFailure() + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> node(buildContext) {} + is NavTarget.LoggedInFlow -> { + val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return node(buildContext) {}.also { + Timber.w("Couldn't find any session, go through SplashScreen") + } + val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) + val callback = object : LoggedInAppScopeFlowNode.Callback { + override fun onOpenBugReport() { + plugins().forEach { it.onOpenBugReport() } + } + + override fun onAddAccount() { + plugins().forEach { it.onAddAccount() } + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } + } + } + + suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { + return waitForChildAttached { navTarget -> + navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId + } + .attachSession() + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView( + transitionHandler = rememberBackstackFader() + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 6ab8fb0cfda..0743acad1de 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -34,6 +34,7 @@ import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView +import io.element.android.appnav.store.getLatestSessionId import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint @@ -43,7 +44,6 @@ import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.createNode -import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator @@ -108,10 +108,7 @@ class RootFlowNode( when (navState.loggedInState) { is LoggedInState.LoggedIn -> { if (navState.loggedInState.isTokenValid) { - tryToRestoreLatestSession( - onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, - onFailure = { switchToNotLoggedInFlow(null) } - ) + switchToLoggedInFlow() } else { switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) } @@ -124,8 +121,8 @@ class RootFlowNode( .launchIn(lifecycleScope) } - private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { - backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) + private fun switchToLoggedInFlow() { + backstack.safeRoot(NavTarget.LoggedInFlow) } private fun switchToNotLoggedInFlow(params: LoginParams?) { @@ -138,34 +135,6 @@ class RootFlowNode( backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) } - private suspend fun restoreSessionIfNeeded( - sessionId: SessionId, - onFailure: () -> Unit, - onSuccess: (SessionId) -> Unit, - ) { - matrixSessionCache.getOrRestore(sessionId) - .onSuccess { - Timber.v("Succeed to restore session $sessionId") - onSuccess(sessionId) - } - .onFailure { - Timber.e(it, "Failed to restore session $sessionId") - onFailure() - } - } - - private suspend fun tryToRestoreLatestSession( - onSuccess: (SessionId) -> Unit, - onFailure: () -> Unit - ) { - val latestSessionId = sessionStore.getLatestSessionId() - if (latestSessionId == null) { - onFailure() - return - } - restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess) - } - private fun onOpenBugReport() { backstack.push(NavTarget.BugReport) } @@ -199,10 +168,7 @@ class RootFlowNode( ) : NavTarget @Parcelize - data class LoggedInFlow( - val sessionId: SessionId, - val navId: Int - ) : NavTarget + data object LoggedInFlow : NavTarget @Parcelize data class SignedOutFlow( @@ -216,11 +182,7 @@ class RootFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { is NavTarget.LoggedInFlow -> { - val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { - Timber.w("Couldn't find any session, go through SplashScreen") - } - val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) - val callback = object : LoggedInAppScopeFlowNode.Callback { + val callback = object : LoggedInAccountSwitcherNode.Callback { override fun onOpenBugReport() { backstack.push(NavTarget.BugReport) } @@ -229,7 +191,7 @@ class RootFlowNode( backstack.push(NavTarget.NotLoggedInFlow(null)) } } - createNode(buildContext, plugins = listOf(inputs, callback)) + createNode(buildContext, plugins = listOf(callback)) } is NavTarget.NotLoggedInFlow -> { val callback = object : NotLoggedInFlowNode.Callback { @@ -267,11 +229,7 @@ class RootFlowNode( val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback { override fun onSelectAccount(sessionId: SessionId) { lifecycleScope.launch { - if (sessionId == navTarget.currentSessionId) { - // Ensure that the account selection Node is removed from the backstack - // Do not pop when the account is changed to avoid a UI flicker. - backstack.pop() - } + backstack.pop() attachSession(sessionId).apply { if (navTarget.intent != null) { attachIncomingShare(navTarget.intent) @@ -432,11 +390,7 @@ class RootFlowNode( private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { // Ensure that the session is the latest one sessionStore.setLatestSession(sessionId.value) - return waitForChildAttached { navTarget -> - navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId - } - .attachSession() + return waitForChildAttached() + .attachSession(sessionId) } } - -private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/store/SessionStoreExt.kt b/appnav/src/main/kotlin/io/element/android/appnav/store/SessionStoreExt.kt new file mode 100644 index 00000000000..5f7d1be03d7 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/store/SessionStoreExt.kt @@ -0,0 +1,13 @@ +/* + * 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.store + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore + +internal suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId) From 883b1f37c7207512d9f6605749977ad9045846a1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 19 Sep 2025 15:10:42 +0200 Subject: [PATCH 63/71] Rename Node to follow naming convention. --- ...ntSwitcherNode.kt => LoggedInAccountSwitcherFlowNode.kt} | 4 ++-- .../main/kotlin/io/element/android/appnav/RootFlowNode.kt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename appnav/src/main/kotlin/io/element/android/appnav/{LoggedInAccountSwitcherNode.kt => LoggedInAccountSwitcherFlowNode.kt} (98%) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherFlowNode.kt similarity index 98% rename from appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt rename to appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherFlowNode.kt index bbbe12acbe0..ad152450666 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherFlowNode.kt @@ -40,13 +40,13 @@ import timber.log.Timber @ContributesNode(AppScope::class) @Inject -class LoggedInAccountSwitcherNode( +class LoggedInAccountSwitcherFlowNode( @Assisted val buildContext: BuildContext, @Assisted plugins: List, private val navStateFlowFactory: RootNavStateFlowFactory, private val sessionStore: SessionStore, private val matrixSessionCache: MatrixSessionCache, -) : BaseFlowNode( +) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, savedStateMap = buildContext.savedStateMap, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 0743acad1de..1d1ea312222 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -182,7 +182,7 @@ class RootFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { is NavTarget.LoggedInFlow -> { - val callback = object : LoggedInAccountSwitcherNode.Callback { + val callback = object : LoggedInAccountSwitcherFlowNode.Callback { override fun onOpenBugReport() { backstack.push(NavTarget.BugReport) } @@ -191,7 +191,7 @@ class RootFlowNode( backstack.push(NavTarget.NotLoggedInFlow(null)) } } - createNode(buildContext, plugins = listOf(callback)) + createNode(buildContext, plugins = listOf(callback)) } is NavTarget.NotLoggedInFlow -> { val callback = object : NotLoggedInFlowNode.Callback { @@ -390,7 +390,7 @@ class RootFlowNode( private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { // Ensure that the session is the latest one sessionStore.setLatestSession(sessionId.value) - return waitForChildAttached() + return waitForChildAttached() .attachSession(sessionId) } } From e409630856d7a7e741548016d7afe174ff1b40f7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 19 Sep 2025 15:43:36 +0200 Subject: [PATCH 64/71] Fix navigation issue after login. --- .../src/main/kotlin/io/element/android/appnav/RootFlowNode.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 1d1ea312222..5a66f8ea60f 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -21,6 +21,7 @@ import com.bumble.appyx.core.node.node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import dev.zacsweers.metro.AppScope @@ -122,7 +123,7 @@ class RootFlowNode( } private fun switchToLoggedInFlow() { - backstack.safeRoot(NavTarget.LoggedInFlow) + backstack.newRoot(NavTarget.LoggedInFlow) } private fun switchToNotLoggedInFlow(params: LoginParams?) { From 42356cf912d8a04fdb4471fd7f3ea678335ae60b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 22 Sep 2025 16:34:30 +0200 Subject: [PATCH 65/71] Remove unused import --- .../services/analytics/impl/DefaultAnalyticsServiceTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt index 638fcb5bb87..1e5a54fb261 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt @@ -19,7 +19,6 @@ import im.vector.app.features.analytics.plan.UserProperties import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.test.InMemorySessionStore -import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver import io.element.android.services.analytics.impl.store.AnalyticsStore import io.element.android.services.analytics.impl.store.FakeAnalyticsStore From 1630aeb5aec00aba170d8819e3ad2be9eaf7e68c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Sep 2025 12:25:51 +0200 Subject: [PATCH 66/71] Revert "Fix navigation issue after login." This reverts commit e409630856d7a7e741548016d7afe174ff1b40f7. --- .../src/main/kotlin/io/element/android/appnav/RootFlowNode.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 5a66f8ea60f..1d1ea312222 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -21,7 +21,6 @@ import com.bumble.appyx.core.node.node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.navmodel.backstack.BackStack -import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import dev.zacsweers.metro.AppScope @@ -123,7 +122,7 @@ class RootFlowNode( } private fun switchToLoggedInFlow() { - backstack.newRoot(NavTarget.LoggedInFlow) + backstack.safeRoot(NavTarget.LoggedInFlow) } private fun switchToNotLoggedInFlow(params: LoginParams?) { From b719e4db2de834dbe843a7fdf2e89709faef973e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Sep 2025 12:26:32 +0200 Subject: [PATCH 67/71] Revert "Rename Node to follow naming convention." This reverts commit 883b1f37c7207512d9f6605749977ad9045846a1. --- ...ntSwitcherFlowNode.kt => LoggedInAccountSwitcherNode.kt} | 4 ++-- .../main/kotlin/io/element/android/appnav/RootFlowNode.kt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename appnav/src/main/kotlin/io/element/android/appnav/{LoggedInAccountSwitcherFlowNode.kt => LoggedInAccountSwitcherNode.kt} (98%) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt similarity index 98% rename from appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherFlowNode.kt rename to appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt index ad152450666..bbbe12acbe0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt @@ -40,13 +40,13 @@ import timber.log.Timber @ContributesNode(AppScope::class) @Inject -class LoggedInAccountSwitcherFlowNode( +class LoggedInAccountSwitcherNode( @Assisted val buildContext: BuildContext, @Assisted plugins: List, private val navStateFlowFactory: RootNavStateFlowFactory, private val sessionStore: SessionStore, private val matrixSessionCache: MatrixSessionCache, -) : BaseFlowNode( +) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, savedStateMap = buildContext.savedStateMap, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 1d1ea312222..0743acad1de 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -182,7 +182,7 @@ class RootFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { is NavTarget.LoggedInFlow -> { - val callback = object : LoggedInAccountSwitcherFlowNode.Callback { + val callback = object : LoggedInAccountSwitcherNode.Callback { override fun onOpenBugReport() { backstack.push(NavTarget.BugReport) } @@ -191,7 +191,7 @@ class RootFlowNode( backstack.push(NavTarget.NotLoggedInFlow(null)) } } - createNode(buildContext, plugins = listOf(callback)) + createNode(buildContext, plugins = listOf(callback)) } is NavTarget.NotLoggedInFlow -> { val callback = object : NotLoggedInFlowNode.Callback { @@ -390,7 +390,7 @@ class RootFlowNode( private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { // Ensure that the session is the latest one sessionStore.setLatestSession(sessionId.value) - return waitForChildAttached() + return waitForChildAttached() .attachSession(sessionId) } } From c8024899acefc6803bdd2977ac7efebfcc238b42 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Sep 2025 12:26:51 +0200 Subject: [PATCH 68/71] Revert "Introduce LoggedInAccountSwitcherNode, to improve animation when switching between accounts." This reverts commit 9c698ff8152aceb5fd2b8b5ab5f609d28de64d24. --- .../appnav/LoggedInAccountSwitcherNode.kt | 160 ------------------ .../io/element/android/appnav/RootFlowNode.kt | 66 ++++++-- .../android/appnav/store/SessionStoreExt.kt | 13 -- 3 files changed, 56 insertions(+), 183 deletions(-) delete mode 100644 appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt delete mode 100644 appnav/src/main/kotlin/io/element/android/appnav/store/SessionStoreExt.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt deleted file mode 100644 index bbbe12acbe0..00000000000 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAccountSwitcherNode.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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 android.os.Parcelable -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.lifecycle.lifecycleScope -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.plugins -import com.bumble.appyx.navmodel.backstack.BackStack -import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.Inject -import io.element.android.annotations.ContributesNode -import io.element.android.appnav.di.MatrixSessionCache -import io.element.android.appnav.root.RootNavStateFlowFactory -import io.element.android.appnav.store.getLatestSessionId -import io.element.android.libraries.architecture.BackstackView -import io.element.android.libraries.architecture.BaseFlowNode -import io.element.android.libraries.architecture.createNode -import io.element.android.libraries.architecture.waitForChildAttached -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.sessionstorage.api.LoggedInState -import io.element.android.libraries.sessionstorage.api.SessionStore -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.Parcelize -import timber.log.Timber - -@ContributesNode(AppScope::class) -@Inject -class LoggedInAccountSwitcherNode( - @Assisted val buildContext: BuildContext, - @Assisted plugins: List, - private val navStateFlowFactory: RootNavStateFlowFactory, - private val sessionStore: SessionStore, - private val matrixSessionCache: MatrixSessionCache, -) : BaseFlowNode( - backstack = BackStack( - initialElement = NavTarget.Root, - savedStateMap = buildContext.savedStateMap, - ), - buildContext = buildContext, - plugins = plugins -) { - interface Callback : Plugin { - fun onOpenBugReport() - fun onAddAccount() - } - - sealed interface NavTarget : Parcelable { - @Parcelize - data object Root : NavTarget - - @Parcelize - data class LoggedInFlow( - val sessionId: SessionId, - val navId: Int - ) : NavTarget - } - - override fun onBuilt() { - super.onBuilt() - observeNavState() - } - - private fun observeNavState() { - navStateFlowFactory.create(buildContext.savedStateMap) - .distinctUntilChanged() - .onEach { navState -> - Timber.v("navState=$navState") - if (navState.loggedInState is LoggedInState.LoggedIn && navState.loggedInState.isTokenValid) { - tryToRestoreLatestSession( - onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, - onFailure = { }, - ) - } - } - .launchIn(lifecycleScope) - } - - private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { - backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) - } - - private suspend fun tryToRestoreLatestSession( - onSuccess: (SessionId) -> Unit, - onFailure: () -> Unit - ) { - val latestSessionId = sessionStore.getLatestSessionId() - if (latestSessionId == null) { - onFailure() - return - } - restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess) - } - - private suspend fun restoreSessionIfNeeded( - sessionId: SessionId, - onFailure: () -> Unit, - onSuccess: (SessionId) -> Unit, - ) { - matrixSessionCache.getOrRestore(sessionId) - .onSuccess { - Timber.v("Succeed to restore session $sessionId") - onSuccess(sessionId) - } - .onFailure { - Timber.e(it, "Failed to restore session $sessionId") - onFailure() - } - } - - override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { - return when (navTarget) { - NavTarget.Root -> node(buildContext) {} - is NavTarget.LoggedInFlow -> { - val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return node(buildContext) {}.also { - Timber.w("Couldn't find any session, go through SplashScreen") - } - val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) - val callback = object : LoggedInAppScopeFlowNode.Callback { - override fun onOpenBugReport() { - plugins().forEach { it.onOpenBugReport() } - } - - override fun onAddAccount() { - plugins().forEach { it.onAddAccount() } - } - } - createNode(buildContext, plugins = listOf(inputs, callback)) - } - } - } - - suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { - return waitForChildAttached { navTarget -> - navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId - } - .attachSession() - } - - @Composable - override fun View(modifier: Modifier) { - BackstackView( - transitionHandler = rememberBackstackFader() - ) - } -} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 0743acad1de..6ab8fb0cfda 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -34,7 +34,6 @@ import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView -import io.element.android.appnav.store.getLatestSessionId import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint @@ -44,6 +43,7 @@ import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator @@ -108,7 +108,10 @@ class RootFlowNode( when (navState.loggedInState) { is LoggedInState.LoggedIn -> { if (navState.loggedInState.isTokenValid) { - switchToLoggedInFlow() + tryToRestoreLatestSession( + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, + onFailure = { switchToNotLoggedInFlow(null) } + ) } else { switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) } @@ -121,8 +124,8 @@ class RootFlowNode( .launchIn(lifecycleScope) } - private fun switchToLoggedInFlow() { - backstack.safeRoot(NavTarget.LoggedInFlow) + private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { + backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) } private fun switchToNotLoggedInFlow(params: LoginParams?) { @@ -135,6 +138,34 @@ class RootFlowNode( backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) } + private suspend fun restoreSessionIfNeeded( + sessionId: SessionId, + onFailure: () -> Unit, + onSuccess: (SessionId) -> Unit, + ) { + matrixSessionCache.getOrRestore(sessionId) + .onSuccess { + Timber.v("Succeed to restore session $sessionId") + onSuccess(sessionId) + } + .onFailure { + Timber.e(it, "Failed to restore session $sessionId") + onFailure() + } + } + + private suspend fun tryToRestoreLatestSession( + onSuccess: (SessionId) -> Unit, + onFailure: () -> Unit + ) { + val latestSessionId = sessionStore.getLatestSessionId() + if (latestSessionId == null) { + onFailure() + return + } + restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess) + } + private fun onOpenBugReport() { backstack.push(NavTarget.BugReport) } @@ -168,7 +199,10 @@ class RootFlowNode( ) : NavTarget @Parcelize - data object LoggedInFlow : NavTarget + data class LoggedInFlow( + val sessionId: SessionId, + val navId: Int + ) : NavTarget @Parcelize data class SignedOutFlow( @@ -182,7 +216,11 @@ class RootFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { is NavTarget.LoggedInFlow -> { - val callback = object : LoggedInAccountSwitcherNode.Callback { + val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { + Timber.w("Couldn't find any session, go through SplashScreen") + } + val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) + val callback = object : LoggedInAppScopeFlowNode.Callback { override fun onOpenBugReport() { backstack.push(NavTarget.BugReport) } @@ -191,7 +229,7 @@ class RootFlowNode( backstack.push(NavTarget.NotLoggedInFlow(null)) } } - createNode(buildContext, plugins = listOf(callback)) + createNode(buildContext, plugins = listOf(inputs, callback)) } is NavTarget.NotLoggedInFlow -> { val callback = object : NotLoggedInFlowNode.Callback { @@ -229,7 +267,11 @@ class RootFlowNode( val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback { override fun onSelectAccount(sessionId: SessionId) { lifecycleScope.launch { - backstack.pop() + if (sessionId == navTarget.currentSessionId) { + // Ensure that the account selection Node is removed from the backstack + // Do not pop when the account is changed to avoid a UI flicker. + backstack.pop() + } attachSession(sessionId).apply { if (navTarget.intent != null) { attachIncomingShare(navTarget.intent) @@ -390,7 +432,11 @@ class RootFlowNode( private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { // Ensure that the session is the latest one sessionStore.setLatestSession(sessionId.value) - return waitForChildAttached() - .attachSession(sessionId) + return waitForChildAttached { navTarget -> + navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId + } + .attachSession() } } + +private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/store/SessionStoreExt.kt b/appnav/src/main/kotlin/io/element/android/appnav/store/SessionStoreExt.kt deleted file mode 100644 index 5f7d1be03d7..00000000000 --- a/appnav/src/main/kotlin/io/element/android/appnav/store/SessionStoreExt.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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.store - -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.sessionstorage.api.SessionStore - -internal suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId) From 8772f2eac147fce42e61ff66bdc7fd7f6b4b0797 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Sep 2025 12:47:52 +0200 Subject: [PATCH 69/71] Metro now have `@AssistedInject`. --- .../android/libraries/accountselect/impl/AccountSelectNode.kt | 4 ++-- .../troubleshoot/impl/history/PushHistoryPresenter.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt index 275a3d6470e..5478d9fe433 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt @@ -14,13 +14,13 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.matrix.api.core.SessionId @ContributesNode(AppScope::class) -@Inject +@AssistedInject class AccountSelectNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt index 44e8c848717..b98fcee970e 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt @@ -16,7 +16,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.MatrixClient @@ -31,7 +31,7 @@ fun interface PushHistoryNavigator { fun navigateTo(roomId: RoomId, eventId: EventId) } -@Inject +@AssistedInject class PushHistoryPresenter( @Assisted private val pushHistoryNavigator: PushHistoryNavigator, private val pushService: PushService, From efa706eb5708570adf6f9a076c095cdc523a6a51 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 26 Sep 2025 11:03:02 +0000 Subject: [PATCH 70/71] Update screenshots --- ...res.preferences.impl.root_PreferencesRootViewDark_0_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_1_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_0_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_1_en.png | 4 ++-- ...s.designsystem.components.avatar_Avatar_Avatars_114_en.png | 3 +++ ...s.designsystem.components.avatar_Avatar_Avatars_115_en.png | 3 +++ ...s.designsystem.components.avatar_Avatar_Avatars_116_en.png | 3 +++ 7 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_114_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_115_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_116_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png index 782a8aba827..aca3254639d 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5c95596f8a3e78692c7fa13b95a3491d320e4a89273c61cc22595817bf4e846 -size 38104 +oid sha256:bea2b1b58e31957bfef5a6317753a11b4ef34477858af0ab9a515a57f837af76 +size 39307 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png index d12ebf79be7..ecd34d97af3 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7cfb090fcb56f52c935f72ec52659f574aa088a663a991c7cd49688d42001388 -size 37944 +oid sha256:44d8cececc9cf14291f2a23a762ed8628139b5fb3f15090b6ca0a2e733a3ac5a +size 39137 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png index 3f96c2234e5..1c61185e1dd 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b092653f3c1192c3c2e0f0eb8803d20a0879af6bdcde2d51f6f6d4894ccc7c52 -size 38914 +oid sha256:61c4546a82519138144a2f1ab82f5200a0469fe428b06e2691f443e04df37ba6 +size 40217 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png index 499340fa1e0..b3452cc94f3 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78411c84878a5f97b2208b7d688094148fbf88660b1b8fbc0e5fa7b2aebbbf5b -size 38968 +oid sha256:2ae3fa88e84abbc6d83ff55b613758118c03a37a99d7e95cc40369e5222edc2e +size 40266 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_114_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_114_en.png new file mode 100644 index 00000000000..9b1636f3bd5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_114_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab6848052b8bb306bbe8bbd87a62599047f7e530b70a17417b7ba51f2d90ecf5 +size 14278 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_115_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_115_en.png new file mode 100644 index 00000000000..73fb759161b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_115_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ddb1b57c91c8d3adec9b79307d52a36ac4516e7020f2c3eafc9343fd9d9e368 +size 13536 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_116_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_116_en.png new file mode 100644 index 00000000000..73fbe1fcdf8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_116_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f80fd46fb18b92e462e079647b42f6cb8cd101f900120d4927d3101defe2dd36 +size 16213 From 419b304228c4999dcc895f3760499128d2eaeda9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 26 Sep 2025 15:06:18 +0200 Subject: [PATCH 71/71] Introduce DelegateTransitionHandler and use it in RootFlowNode --- .../io/element/android/appnav/RootFlowNode.kt | 131 +++++++++--------- .../appyx/DelegateTransitionHandler.kt | 35 +++++ 2 files changed, 97 insertions(+), 69 deletions(-) create mode 100644 libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DelegateTransitionHandler.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 5835fefa560..d71fbf2eef5 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -9,6 +9,8 @@ package io.element.android.appnav import android.content.Intent import android.os.Parcelable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -23,6 +25,8 @@ import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject @@ -42,6 +46,7 @@ import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.core.uri.ensureProtocol @@ -63,9 +68,7 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import timber.log.Timber -@ContributesNode(AppScope::class) -@AssistedInject -class RootFlowNode( +@ContributesNode(AppScope::class) @AssistedInject class RootFlowNode( @Assisted val buildContext: BuildContext, @Assisted plugins: List, private val sessionStore: SessionStore, @@ -101,27 +104,24 @@ class RootFlowNode( } private fun observeNavState() { - navStateFlowFactory.create(buildContext.savedStateMap) - .distinctUntilChanged() - .onEach { navState -> - Timber.v("navState=$navState") - when (navState.loggedInState) { - is LoggedInState.LoggedIn -> { - if (navState.loggedInState.isTokenValid) { - tryToRestoreLatestSession( - onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, - onFailure = { switchToNotLoggedInFlow(null) } - ) - } else { - switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) - } - } - LoggedInState.NotLoggedIn -> { - switchToNotLoggedInFlow(null) + navStateFlowFactory.create(buildContext.savedStateMap).distinctUntilChanged().onEach { navState -> + Timber.v("navState=$navState") + when (navState.loggedInState) { + is LoggedInState.LoggedIn -> { + if (navState.loggedInState.isTokenValid) { + tryToRestoreLatestSession( + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, + onFailure = { switchToNotLoggedInFlow(null) } + ) + } else { + switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) } } + LoggedInState.NotLoggedIn -> { + switchToNotLoggedInFlow(null) + } } - .launchIn(lifecycleScope) + }.launchIn(lifecycleScope) } private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { @@ -143,20 +143,17 @@ class RootFlowNode( onFailure: () -> Unit, onSuccess: (SessionId) -> Unit, ) { - matrixSessionCache.getOrRestore(sessionId) - .onSuccess { - Timber.v("Succeed to restore session $sessionId") - onSuccess(sessionId) - } - .onFailure { - Timber.e(it, "Failed to restore session $sessionId") - onFailure() - } + matrixSessionCache.getOrRestore(sessionId).onSuccess { + Timber.v("Succeed to restore session $sessionId") + onSuccess(sessionId) + }.onFailure { + Timber.e(it, "Failed to restore session $sessionId") + onFailure() + } } private suspend fun tryToRestoreLatestSession( - onSuccess: (SessionId) -> Unit, - onFailure: () -> Unit + onSuccess: (SessionId) -> Unit, onFailure: () -> Unit ) { val latestSessionId = sessionStore.getLatestSessionId() if (latestSessionId == null) { @@ -178,39 +175,45 @@ class RootFlowNode( modifier = modifier, onOpenBugReport = this::onOpenBugReport, ) { - BackstackView() + val backstackSlider = rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + val backstackFader = rememberBackstackFader( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + val transitionHandler = rememberDelegateTransitionHandler { navTarget -> + when (navTarget) { + is NavTarget.SplashScreen, + is NavTarget.LoggedInFlow -> backstackFader + else -> backstackSlider + } + } + BackstackView(transitionHandler = transitionHandler) } } sealed interface NavTarget : Parcelable { - @Parcelize - data object SplashScreen : NavTarget + @Parcelize data object SplashScreen : NavTarget - @Parcelize - data class AccountSelect( + @Parcelize data class AccountSelect( val currentSessionId: SessionId, val intent: Intent?, val permalinkData: PermalinkData?, ) : NavTarget - @Parcelize - data class NotLoggedInFlow( + @Parcelize data class NotLoggedInFlow( val params: LoginParams? ) : NavTarget - @Parcelize - data class LoggedInFlow( - val sessionId: SessionId, - val navId: Int + @Parcelize data class LoggedInFlow( + val sessionId: SessionId, val navId: Int ) : NavTarget - @Parcelize - data class SignedOutFlow( + @Parcelize data class SignedOutFlow( val sessionId: SessionId ) : NavTarget - @Parcelize - data object BugReport : NavTarget + @Parcelize data object BugReport : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -243,13 +246,11 @@ class RootFlowNode( createNode(buildContext, plugins = listOf(params, callback)) } is NavTarget.SignedOutFlow -> { - signedOutEntryPoint.nodeBuilder(this, buildContext) - .params( - SignedOutEntryPoint.Params( - sessionId = navTarget.sessionId - ) + signedOutEntryPoint.nodeBuilder(this, buildContext).params( + SignedOutEntryPoint.Params( + sessionId = navTarget.sessionId ) - .build() + ).build() } NavTarget.SplashScreen -> splashNode(buildContext) NavTarget.BugReport -> { @@ -258,10 +259,7 @@ class RootFlowNode( backstack.pop() } } - bugReportEntryPoint - .nodeBuilder(this, buildContext) - .callback(callback) - .build() + bugReportEntryPoint.nodeBuilder(this, buildContext).callback(callback).build() } is NavTarget.AccountSelect -> { val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback { @@ -286,10 +284,7 @@ class RootFlowNode( backstack.pop() } } - accountSelectEntryPoint - .nodeBuilder(this, buildContext) - .callback(callback) - .build() + accountSelectEntryPoint.nodeBuilder(this, buildContext).callback(callback).build() } } } @@ -416,13 +411,12 @@ class RootFlowNode( private suspend fun navigateTo(deeplinkData: DeeplinkData) { Timber.d("Navigating to $deeplinkData") - attachSession(deeplinkData.sessionId) - .apply { - when (deeplinkData) { - is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState - is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true) - } + attachSession(deeplinkData.sessionId).apply { + when (deeplinkData) { + is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState + is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias(), clearBackstack = true) } + } } private fun onOidcAction(oidcAction: OidcAction) { @@ -434,8 +428,7 @@ class RootFlowNode( sessionStore.setLatestSession(sessionId.value) return waitForChildAttached { navTarget -> navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId - } - .attachSession() + }.attachSession() } } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DelegateTransitionHandler.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DelegateTransitionHandler.kt new file mode 100644 index 00000000000..642ff6fc3a7 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DelegateTransitionHandler.kt @@ -0,0 +1,35 @@ +/* + * 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.libraries.architecture.appyx + +import android.annotation.SuppressLint +import androidx.compose.animation.core.Transition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler +import com.bumble.appyx.core.navigation.transition.TransitionDescriptor + +/** + * A [ModifierTransitionHandler] that delegates the creation of the modifier to another handler + * based on the [NavTarget]. The idea is to allow different transitions for different [NavTarget]s. + */ +class DelegateTransitionHandler( + private val handlerProvider: (NavTarget) -> ModifierTransitionHandler, +) : ModifierTransitionHandler() { + @SuppressLint("ModifierFactoryExtensionFunction") + override fun createModifier(modifier: Modifier, transition: Transition, descriptor: TransitionDescriptor): Modifier { + return handlerProvider(descriptor.element).createModifier(modifier, transition, descriptor) + } +} + +@Composable +fun rememberDelegateTransitionHandler( + handlerProvider: (NavTarget) -> ModifierTransitionHandler, +): ModifierTransitionHandler = + remember(handlerProvider) { DelegateTransitionHandler(handlerProvider = handlerProvider) }