From 73a6ba2849fe8a26ddc2df8a1fa49336da10cc4e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 26 Sep 2025 15:45:06 +0200 Subject: [PATCH] Multi accounts - experimental first implementation (#5285) * 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. * Multi accounts - first implementation. * Multi accounts - Prevent user from logging twice with the same account * Multi accounts - ignore automatic GoBack in case of error. * Multi accounts - update first view when adding an account. * Rename method storeData to addSession. * Multi accounts - handle account switch when coming from a notification * Multi accounts - handle login link when there is already an account. * Multi accounts - handle click on push history for not current account. * Multi accounts - improve layout and add preview. * Add accountselect modules * Multi accounts - incoming share with account selection * Multi accounts - check the feature flag before allowing login using login link. * Multi accounts - swipe on account icon * Cleanup * Multi accounts - fix other implementation of SessionStore * Multi accounts - fix PreferencesRootPresenterTest * Multi accounts - Add test on AccountSelectPresenter * Multi accounts - Fix test on HomePresenter - WIP * Update database to be able to sort accounts by creation date. * Add unit test on takeCurrentUserWithNeighbors * Fix test and improve code. * Add exception * Multi accounts - handle permalink * Code quality * Multi accounts - localization * Fix issue after rebase on develop * Fix issue after rebase on develop * Fix tests * Fix tests * Fix tests * Fix tests * Update Multi accounts flag details. * Add missing test on DatabaseSessionStore * Add missing preview on LoginModeView * Remove dead code. * Add missing preview on PushHistoryView * Document API. * Rename API and update test. * Remove MatrixAuthenticationService.loggedInStateFlow() * Update screenshots * Remove unused import * Add exception * Fix compilation issue after rebase on develop. * Update screenshots * Fix test * Avoid calling getLatestSession() twice * Rename `matrixUserAndNeighbors` to `currentUserAndNeighbors` * Extract code to its own class. * Add comment to clarify the code. * 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. * Let the RustMatrixClient update the profile in the session database * Fix test. * When logging out from Pin code screen, logout from all the sessions. tom * 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. * Change test in RustMatrixAuthenticationServiceTest * Do not use MatrixAuthenticationService in RootFlowNode, only use SessionStore * Remove MatrixAuthenticationService.getLatestSessionId() * Fix compilation issue after merging develop * Add test on DefaultAccountSelectEntryPoint * Fix compilation issue after merging develop * Introduce LoggedInAccountSwitcherNode, to improve animation when switching between accounts. * Rename Node to follow naming convention. * Fix navigation issue after login. * Remove unused import * Revert "Fix navigation issue after login." This reverts commit e409630856d7a7e741548016d7afe174ff1b40f7. * Revert "Rename Node to follow naming convention." This reverts commit 883b1f37c7207512d9f6605749977ad9045846a1. * Revert "Introduce LoggedInAccountSwitcherNode, to improve animation when switching between accounts." This reverts commit 9c698ff8152aceb5fd2b8b5ab5f609d28de64d24. * Metro now have `@AssistedInject`. * Update screenshots * Introduce DelegateTransitionHandler and use it in RootFlowNode --------- Co-authored-by: ElementBot Co-authored-by: ganfra --- appnav/build.gradle.kts | 2 + .../appnav/LoggedInAppScopeFlowNode.kt | 5 + .../android/appnav/LoggedInFlowNode.kt | 12 +- .../io/element/android/appnav/RootFlowNode.kt | 277 ++++++++++++------ .../appnav/intent/IntentResolverTest.kt | 4 +- features/home/impl/build.gradle.kts | 1 + .../impl/CurrentUserWithNeighborsBuilder.kt | 69 +++++ .../android/features/home/impl/HomeEvents.kt | 3 + .../features/home/impl/HomePresenter.kt | 23 +- .../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 ++++++- .../CurrentUserWithNeighborsBuilderTest.kt | 222 ++++++++++++++ .../features/home/impl/HomePresenterTest.kt | 52 +++- features/login/impl/build.gradle.kts | 2 + .../features/login/impl/LoginFlowNode.kt | 2 +- .../features/login/impl/login/LoginHelper.kt | 7 +- .../login/impl/login/LoginModeView.kt | 10 +- .../impl/login/LoginModeViewErrorProvider.kt | 18 ++ .../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 | 130 +++++--- .../ConfirmAccountProviderPresenterTest.kt | 27 +- .../onboarding/OnBoardingPresenterTest.kt | 22 ++ .../screens/onboarding/OnboardingViewTest.kt | 18 ++ .../preferences/api/PreferencesEntryPoint.kt | 4 +- features/preferences/impl/build.gradle.kts | 1 + .../preferences/impl/PreferencesFlowNode.kt | 9 +- .../impl/root/PreferencesRootEvents.kt | 3 + .../impl/root/PreferencesRootNode.kt | 6 + .../impl/root/PreferencesRootPresenter.kt | 34 +++ .../impl/root/PreferencesRootState.kt | 3 + .../impl/root/PreferencesRootStateProvider.kt | 7 +- .../impl/root/PreferencesRootView.kt | 60 +++- .../impl/DefaultPreferencesEntryPointTest.kt | 4 +- .../impl/root/PreferencesRootPresenterTest.kt | 42 +++ .../signedout/impl/SignedOutStateProvider.kt | 4 + libraries/accountselect/api/build.gradle.kts | 18 ++ .../api/AccountSelectEntryPoint.kt | 28 ++ libraries/accountselect/impl/build.gradle.kts | 35 +++ .../accountselect/impl/AccountSelectNode.kt | 49 ++++ .../impl/AccountSelectPresenter.kt | 44 +++ .../accountselect/impl/AccountSelectState.kt | 15 + .../impl/AccountSelectStateProvider.kt | 27 ++ .../accountselect/impl/AccountSelectView.kt | 88 ++++++ .../impl/DefaultAccountSelectEntryPoint.kt | 36 +++ .../impl/AccountSelectPresenterTest.kt | 78 +++++ .../DefaultAccountSelectEntryPointTest.kt | 44 +++ .../appyx/DelegateTransitionHandler.kt | 35 +++ .../components/avatar/AvatarSize.kt | 2 + .../libraries/featureflag/api/FeatureFlags.kt | 10 +- .../api/auth/AuthenticationException.kt | 1 + .../matrix/api/permalink/PermalinkData.kt | 5 +- .../libraries/matrix/impl/RustMatrixClient.kt | 21 +- .../impl/auth/AuthenticationException.kt | 1 + .../auth/RustMatrixAuthenticationService.kt | 33 ++- .../libraries/matrix/impl/mapper/Session.kt | 9 + .../impl/RustMatrixClientFactoryTest.kt | 4 +- .../matrix/impl/RustMatrixClientTest.kt | 48 ++- .../impl/fixtures/fakes/FakeFfiClient.kt | 3 +- .../fixtures/fakes/FakeFfiClientBuilder.kt | 1 - .../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 +- .../sessionstorage/api/SessionData.kt | 8 + .../sessionstorage/api/SessionStore.kt | 40 +++ .../session-storage/impl/build.gradle.kts | 2 +- .../impl/DatabaseSessionStore.kt | 71 ++++- .../sessionstorage/impl/SessionDataMapper.kt | 8 + .../impl/src/main/sqldelight/databases/10.db | Bin 0 -> 12288 bytes .../libraries/matrix/session/SessionData.sq | 18 +- .../impl/src/main/sqldelight/migrations/9.sqm | 9 + .../impl/DatabaseSessionStoreTest.kt | 133 +++++++-- .../libraries/sessionstorage/impl/Fixtures.kt | 4 + .../test/InMemorySessionStore.kt | 10 + .../sessionstorage/test/SessionData.kt | 10 +- .../troubleshoot/api/PushHistoryEntryPoint.kt | 3 +- .../impl/history/PushHistoryEvents.kt | 5 + .../impl/history/PushHistoryNode.kt | 12 +- .../impl/history/PushHistoryPresenter.kt | 32 +- .../impl/history/PushHistoryState.kt | 1 + .../impl/history/PushHistoryStateProvider.kt | 5 + .../impl/history/PushHistoryView.kt | 17 +- .../DefaultPushHistoryEntryPointTest.kt | 16 +- .../impl/history/PushHistoryPresenterTest.kt | 57 ++++ .../impl/history/PushHistoryViewTest.kt | 24 +- .../kotlin/extension/DependencyHandleScope.kt | 1 + .../analytics/impl/DefaultAnalyticsService.kt | 8 +- .../impl/DefaultAnalyticsServiceTest.kt | 6 +- .../tests/konsist/KonsistClassNameTest.kt | 1 + .../tests/konsist/KonsistPreviewTest.kt | 1 + ...ultRoomListTopBarMultiAccount_Day_0_en.png | 3 + ...tRoomListTopBarMultiAccount_Night_0_en.png | 3 + ...ogin.impl.login_LoginModeView_Day_5_en.png | 3 + ...in.impl.login_LoginModeView_Night_5_en.png | 3 + ...ens.onboarding_OnBoardingView_Day_7_en.png | 3 + ...s.onboarding_OnBoardingView_Night_7_en.png | 3 + ...impl.root_MultiAccountSection_Day_0_en.png | 3 + ...pl.root_MultiAccountSection_Night_0_en.png | 3 + ...impl.root_PreferencesRootViewDark_0_en.png | 4 +- ...impl.root_PreferencesRootViewDark_1_en.png | 4 +- ...mpl.root_PreferencesRootViewLight_0_en.png | 4 +- ...mpl.root_PreferencesRootViewLight_1_en.png | 4 +- ...select.impl_AccountSelectView_Day_0_en.png | 3 + ...select.impl_AccountSelectView_Day_1_en.png | 3 + ...lect.impl_AccountSelectView_Night_0_en.png | 3 + ...lect.impl_AccountSelectView_Night_1_en.png | 3 + ...omponents.avatar_Avatar_Avatars_114_en.png | 3 + ...omponents.avatar_Avatar_Avatars_115_en.png | 3 + ...omponents.avatar_Avatar_Avatars_116_en.png | 3 + ....impl.history_PushHistoryView_Day_3_en.png | 3 + ...mpl.history_PushHistoryView_Night_3_en.png | 3 + 117 files changed, 2161 insertions(+), 281 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt create mode 100644 features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt 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/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 create mode 100644 libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPointTest.kt create mode 100644 libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DelegateTransitionHandler.kt create mode 100644 libraries/session-storage/impl/src/main/sqldelight/databases/10.db create mode 100644 libraries/session-storage/impl/src/main/sqldelight/migrations/9.sqm 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.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/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_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 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/appnav/build.gradle.kts b/appnav/build.gradle.kts index 3ea6f9d1c9..b200672f94 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -26,9 +26,11 @@ 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) + 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/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index 4b43b7d43f..290a351e86 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 56cb8e6d96..a15c87ed77 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -76,7 +76,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 @@ -139,6 +138,7 @@ class LoggedInFlowNode( ) { interface Callback : Plugin { fun onOpenBugReport() + fun onAddAccount() } private val loggedInFlowProcessor = LoggedInEventProcessor( @@ -393,6 +393,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() } } @@ -405,11 +409,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/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 5eb57c0281..d71fbf2eef 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 @@ -39,13 +43,17 @@ import io.element.android.features.login.api.accesscontrol.AccountProviderAccess 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.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 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.core.SessionId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -56,12 +64,11 @@ 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 -@ContributesNode(AppScope::class) -@AssistedInject -class RootFlowNode( +@ContributesNode(AppScope::class) @AssistedInject class RootFlowNode( @Assisted val buildContext: BuildContext, @Assisted plugins: List, private val sessionStore: SessionStore, @@ -71,9 +78,11 @@ class RootFlowNode( private val presenter: RootPresenter, private val bugReportEntryPoint: BugReportEntryPoint, private val signedOutEntryPoint: SignedOutEntryPoint, + private val accountSelectEntryPoint: AccountSelectEntryPoint, private val intentResolver: IntentResolver, private val oidcActionFlow: OidcActionFlow, private val bugReporter: BugReporter, + private val featureFlagService: FeatureFlagService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.SplashScreen, @@ -95,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) { @@ -137,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) { @@ -172,32 +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 NotLoggedInFlow( + @Parcelize data class AccountSelect( + val currentSessionId: SessionId, + val intent: Intent?, + val permalinkData: PermalinkData?, + ) : NavTarget + + @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 { @@ -211,6 +227,10 @@ class RootFlowNode( override fun onOpenBugReport() { backstack.push(NavTarget.BugReport) } + + override fun onAddAccount() { + backstack.push(NavTarget.NotLoggedInFlow(null)) + } } createNode(buildContext, plugins = listOf(inputs, callback)) } @@ -226,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 -> { @@ -241,10 +259,32 @@ 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 { + 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() + } + attachSession(sessionId).apply { + if (navTarget.intent != null) { + attachIncomingShare(navTarget.intent) + } else if (navTarget.permalinkData != null) { + attachPermalinkData(navTarget.permalinkData) + } + } + } + } + + override fun onCancel() { + backstack.pop() + } + } + accountSelectEntryPoint.nodeBuilder(this, buildContext).callback(callback).build() } } } @@ -267,19 +307,29 @@ class RootFlowNode( } private suspend fun onLoginLink(params: LoginParams) { - // Is there a session already? - val latestSessionId = sessionStore.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()) { + 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 { + Timber.w("Login link ignored, multi account is disabled") + } } 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") } } @@ -290,56 +340,95 @@ class RootFlowNode( // No session, open login switchToNotLoggedInFlow(null) } else { - 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, + permalinkData = null, + ) + ) + } else { + // Only one account, directly attach the incoming share node. + loggedInFlowNode.attachIncomingShare(intent) + } } } 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 = sessionStore.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) { 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) { oidcActionFlow.post(oidcAction) } - // [sessionId] will be null for permalink. - private suspend fun attachSession(sessionId: SessionId?): LoggedInFlowNode { - // TODO handle multi-session + 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 && (sessionId == null || navTarget.sessionId == sessionId) - } - .attachSession() + navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId + }.attachSession() } } 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 05898c75f3..def1f33253 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/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index b6a83775fc..2bf09c9398 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) + 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/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 0000000000..d19222d44f --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -0,0 +1,69 @@ +/* + * 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 { + /** + * 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, + ): 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] + // 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. + // 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/HomeEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt index 4632e40d5a..bc0f821845 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 90565de292..653a7134f3 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 @@ -29,6 +30,10 @@ 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 +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch @Inject class HomePresenter( @@ -41,10 +46,21 @@ class HomePresenter( private val logoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, ) : Presenter { + private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() + @Composable override fun present(): HomeState { - val matrixUser = client.userProfile.collectAsState() + val coroutineState = rememberCoroutineScope() + val matrixUser by client.userProfile.collectAsState() + val currentUserAndNeighbors by remember { + combine( + client.userProfile, + sessionStore.sessionsFlow(), + currentUserWithNeighborsBuilder::build, + ) + }.collectAsState(initial = persistentListOf(matrixUser)) val isOnline by syncService.isOnline.collectAsState() val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) val roomListState = roomListPresenter.present() @@ -71,6 +87,9 @@ class HomePresenter( is HomeEvents.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } + is HomeEvents.SwitchToAccount -> coroutineState.launch { + sessionStore.setLatestSession(event.sessionId.value) + } } } @@ -82,7 +101,7 @@ class HomePresenter( } val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() return HomeState( - matrixUser = matrixUser.value, + 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 c4fe0ce0fe..d35412734f 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 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 59c8c3c500..7ada259e08 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 @@ -50,6 +51,7 @@ open class HomeStateProvider : PreviewParameterProvider { internal fun aHomeState( matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), + currentUserAndNeighbors: List = listOf(matrixUser), showAvatarIndicator: Boolean = false, hasNetworkConnection: Boolean = true, snackbarMessage: SnackbarMessage? = null, @@ -61,7 +63,7 @@ internal fun aHomeState( directLogoutState: DirectLogoutState = aDirectLogoutState(), eventSink: (HomeEvents) -> Unit = {} ) = HomeState( - matrixUser = matrixUser, + 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 37727712fb..aa4742f074 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, + currentUserAndNeighbors = state.currentUserAndNeighbors, 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 f1f06afe6d..212ba6f29b 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, + currentUserAndNeighbors: 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, + currentUserAndNeighbors = currentUserAndNeighbors, 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, + currentUserAndNeighbors: 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, + currentUserAndNeighbors = currentUserAndNeighbors, showAvatarIndicator = showAvatarIndicator, + onAccountSwitch = onAccountSwitch, onClick = onOpenSettings, ) }, @@ -247,19 +255,67 @@ private fun DefaultRoomListTopBar( @Composable private fun NavigationIcon( - avatarData: AvatarData, + currentUserAndNeighbors: ImmutableList, + showAvatarIndicator: Boolean, + onAccountSwitch: (SessionId) -> Unit, + onClick: () -> Unit, +) { + if (currentUserAndNeighbors.size == 1) { + AccountIcon( + matrixUser = currentUserAndNeighbors.single(), + isCurrentAccount = true, + showAvatarIndicator = showAvatarIndicator, + onClick = onClick, + ) + } else { + // Render a vertical pager + 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(currentUserAndNeighbors[page].userId.value)) + } + } + VerticalPager( + state = pagerState, + modifier = Modifier.height(48.dp), + ) { page -> + AccountIcon( + matrixUser = currentUserAndNeighbors[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"), + currentUserAndNeighbors = 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"), + currentUserAndNeighbors = 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), + currentUserAndNeighbors = aMatrixUserList().take(3).toPersistentList(), + showAvatarIndicator = false, + areSearchResultsDisplayed = false, + scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onAccountSwitch = {}, onSearchClick = {}, displayMenuItems = true, displayFilters = true, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt new file mode 100644 index 0000000000..a03c0d0065 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt @@ -0,0 +1,222 @@ +/* + * 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 CurrentUserWithNeighborsBuilderTest { + @Test + fun `build on empty list returns current user`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser() + val list = listOf() + 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( + 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 = sut.build(matrixUser, list) + 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 sut = CurrentUserWithNeighborsBuilder() + 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 = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + ) + } + + @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 = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + ) + } + + @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( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + ) + val result = sut.build(matrixUser, list) + 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 sut = CurrentUserWithNeighborsBuilder() + 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 = sut.build(matrixUser, list) + 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 sut = CurrentUserWithNeighborsBuilder() + 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 = sut.build(matrixUser, list) + 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 sut = CurrentUserWithNeighborsBuilder() + 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 = sut.build(matrixUser, list) + 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 sut = CurrentUserWithNeighborsBuilder() + 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 = sut.build(matrixUser, list) + 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 sut = CurrentUserWithNeighborsBuilder() + 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 = sut.build(matrixUser, list) + assertThat(result).containsExactly( + MatrixUser( + userId = A_USER_ID, + displayName = "Bob", + avatarUrl = "avatarUrl", + ) + ) + } +} 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 a84dbd6309..8e5b35de99 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 @@ -32,6 +32,9 @@ 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.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.test @@ -54,17 +57,29 @@ class HomePresenterTest { val presenter = createHomePresenter( client = matrixClient, rageshakeFeatureAvailability = { flowOf(false) }, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = matrixClient.sessionId.value, + userDisplayName = null, + userAvatarUrl = null, + ) + ), + ), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID)) + assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo( + MatrixUser(A_USER_ID, null, null) + ) assertThat(initialState.canReportBug).isFalse() + skipItems(1) 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.currentUserAndNeighbors.first()).isEqualTo( + MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL) + ) assertThat(withUserState.showAvatarIndicator).isFalse() assertThat(withUserState.isSpaceFeatureEnabled).isFalse() assertThat(withUserState.showNavigationBar).isFalse() @@ -75,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() @@ -92,6 +110,9 @@ class HomePresenterTest { featureFlagService = FakeFeatureFlagService( initialState = mapOf(FeatureFlags.Space.key to true), ), + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), ) presenter.test { skipItems(1) @@ -105,6 +126,9 @@ class HomePresenterTest { val indicatorService = FakeIndicatorService() val presenter = createHomePresenter( indicatorService = indicatorService, + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -124,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.currentUserAndNeighbors.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 { @@ -152,6 +185,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), ), @@ -185,6 +221,7 @@ internal fun createHomePresenter( indicatorService: IndicatorService = FakeIndicatorService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, + sessionStore: SessionStore = InMemorySessionStore(), ) = HomePresenter( client = client, syncService = syncService, @@ -195,4 +232,5 @@ internal fun createHomePresenter( homeSpacesPresenter = homeSpacesPresenter, rageshakeFeatureAvailability = rageshakeFeatureAvailability, featureFlagService = featureFlagService, + sessionStore = sessionStore, ) diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 314907c04a..690fe13578 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -40,6 +40,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) @@ -56,5 +57,6 @@ 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) } 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 434174f376..4b83190f5d 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 70a0d97781..82ee87c372 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,9 +94,14 @@ class LoginHelper( } private suspend fun onOidcAction(oidcAction: OidcAction) { + 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/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 73127281bc..c3fe5eac47 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 @@ -23,6 +22,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 +89,12 @@ fun LoginModeView( onSubmit = onClearError, ) } + is AuthenticationException.AccountAlreadyLoggedIn -> { + ErrorDialog( + content = stringResource(CommonStrings.error_account_already_logged_in, error.message.orEmpty()), + onSubmit = onClearError, + ) + } else -> { ErrorDialog( content = stringResource(CommonStrings.error_unknown), @@ -113,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 0000000000..dd0a7f353c --- /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") +} 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 a99bf138b9..3652a3df8d 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, + onBackClick = ::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 30ce8e37da..e7e20aa70d 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 @AssistedInject @@ -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 c2896d4ea7..ae5bb79eb5 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 cc41e64480..2eb9bfb301 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 4c44ee132a..fbc4dc6d09 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, + onBackClick: () -> 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, + onBackClick = onBackClick, + ) + } 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,31 @@ 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, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + modifier = modifier, + title = stringResource(CommonStrings.common_add_account), + iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + buttons = { buttons() }, + content = loginView, + onBackClick = onBackClick, + ) +} + @Composable private fun OnBoardingContent(state: OnBoardingState) { Box( @@ -226,27 +277,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 +311,7 @@ internal fun OnBoardingViewPreview( ) = ElementPreview { OnBoardingView( state = state, + onBackClick = {}, onSignInWithQrCode = {}, onSignIn = {}, onCreateAccount = {}, 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 b2f80e4ffe..3978d3be6e 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 17e8eb1dbd..16f6c649fa 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 8ac42b4c93..2f27e2fb2d 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/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 f41d497b18..c0affde2df 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 { @@ -41,9 +40,10 @@ interface PreferencesEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { + fun onAddAccount() 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/build.gradle.kts b/features/preferences/impl/build.gradle.kts index d3edba5833..eb057a9d53 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { testImplementation(projects.features.logout.test) testImplementation(projects.libraries.indicator.test) testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) } 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 3701e54e42..c9ae2862c1 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 @@ -117,6 +116,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() } } @@ -226,8 +229,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/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 ff74cebb51..87074ec7f9 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 00f9bb9b8b..1bb322108f 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 aad8086df6..ebb9a5a867 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 ebe8aaf57f..830c397c59 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 91b32fe12d..604cb10c4d 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,15 +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.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 = 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 544b5f5b3e..56aa4bb126 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 @@ -23,11 +24,14 @@ 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.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 @@ -38,12 +42,15 @@ 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.matrix.ui.components.aMatrixUserList 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 +81,12 @@ fun PreferencesRootView( }, user = state.myUser, ) - + if (state.isMultiAccountEnabled) { + MultiAccountSection( + state = state, + onAddAccountClick = onAddAccountClick, + ) + } // 'Manage my app' section ManageAppSection( state = state, @@ -114,6 +126,38 @@ fun PreferencesRootView( } } +@Composable +private fun ColumnScope.MultiAccountSection( + state: PreferencesRootState, + onAddAccountClick: () -> Unit, +) { + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + state.otherSessions.forEach { matrixUser -> + MatrixUserRow( + modifier = Modifier.clickable { + state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId)) + }, + matrixUser = matrixUser, + avatarSize = AvatarSize.AccountItem, + ) + HorizontalDivider() + } + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), + headlineContent = { + Text(stringResource(CommonStrings.common_add_another_account)) + }, + onClick = onAddAccountClick, + ) + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) +} + @Composable private fun ColumnScope.ManageAppSection( state: PreferencesRootState, @@ -287,6 +331,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) { PreferencesRootView( state = aPreferencesRootState(myUser = matrixUser), onBackClick = {}, + onAddAccountClick = {}, onOpenAnalytics = {}, onOpenRageShake = {}, onOpenDeveloperSettings = {}, @@ -302,3 +347,16 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onDeactivateClick = {}, ) } + +@PreviewsDayNight +@Composable +internal fun MultiAccountSectionPreview() = ElementPreview { + Column { + MultiAccountSection( + state = aPreferencesRootState( + otherSessions = aMatrixUserList(), + ), + onAddAccountClick = {}, + ) + } +} 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 807ff3a5c6..9e1bd70376 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, 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 42fee711a7..0f6eec3c6d 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,23 @@ 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.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 import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -61,6 +69,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 +184,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 = 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() + 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 +223,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 +235,7 @@ class PreferencesRootPresenterTest { directLogoutPresenter = { aDirectLogoutState() }, showDeveloperSettingsProvider = showDeveloperSettingsProvider, rageshakeFeatureAvailability = rageshakeFeatureAvailability, + featureFlagService = featureFlagService, + sessionStore = sessionStore, ) } 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 55e29c9e26..a7b95a8537 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 @@ -43,5 +43,9 @@ private fun aSessionData( passphrase = null, sessionPath = "/a/path/to/a/session", cachePath = "/a/path/to/a/cache", + position = 0, + lastUsageIndex = 0, + userDisplayName = null, + userAvatarUrl = null, ) } diff --git a/libraries/accountselect/api/build.gradle.kts b/libraries/accountselect/api/build.gradle.kts new file mode 100644 index 0000000000..7e0ce303f9 --- /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 0000000000..72da3491de --- /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 onSelectAccount(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 0000000000..ea1fbd52ad --- /dev/null +++ b/libraries/accountselect/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * 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" +} + +setupDependencyInjection() + +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) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.test) +} 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 0000000000..5478d9fe43 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt @@ -0,0 +1,49 @@ +/* + * 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +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) +@AssistedInject +class AccountSelectNode( + @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 onSelectAccount(sessionId: SessionId) { + callbacks.forEach { it.onSelectAccount(sessionId) } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AccountSelectView( + state = state, + onDismiss = ::onDismiss, + onSelectAccount = ::onSelectAccount, + 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 0000000000..dde07e7e38 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.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.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 + +@Inject +class AccountSelectPresenter( + 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() + } + + return AccountSelectState( + accounts = accounts, + ) + } +} 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 0000000000..feaedaf90d --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.impl + +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class AccountSelectState( + val accounts: ImmutableList, +) 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 0000000000..3dc0a22b9c --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt @@ -0,0 +1,27 @@ +/* + * 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(), +) = AccountSelectState( + accounts = accounts.toPersistentList(), +) 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 0000000000..b589df23f6 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt @@ -0,0 +1,88 @@ +/* + * 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.res.stringResource +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 +import io.element.android.libraries.ui.strings.CommonStrings + +@Suppress("MultipleEmitters") // False positive +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSelectView( + state: AccountSelectState, + onSelectAccount: (SessionId) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = { onDismiss() }) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = stringResource(CommonStrings.common_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 { + onSelectAccount(matrixUser.userId) + } + .padding(vertical = 8.dp), + matrixUser = matrixUser, + ) + HorizontalDivider() + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun AccountSelectViewPreview(@PreviewParameter(AccountSelectStateProvider::class) state: AccountSelectState) = ElementPreview { + AccountSelectView( + state = state, + onSelectAccount = {}, + 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 0000000000..baf5ecd5b3 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * 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 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 + +@ContributesBinding(AppScope::class) +@Inject +class DefaultAccountSelectEntryPoint : 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 0000000000..27a8d7d9cf --- /dev/null +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -0,0 +1,78 @@ +/* + * 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.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.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +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() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accounts).isEmpty() + } + } + + @Test + fun `present - multiple accounts case`() = runTest { + val presenter = createAccountSelectPresenter( + 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) + 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", + ) + ) + } + } +} + +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 0000000000..d61dcc89ba --- /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) + } +} 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 0000000000..642ff6fc3a --- /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) } 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 72cf62c76a..f020f06b5e 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 @@ -72,4 +72,6 @@ enum class AvatarSize(val dp: Dp) { RoomPreviewHeader(64.dp), RoomPreviewInviter(56.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 43895bce16..96c452a790 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,13 @@ 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 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, + ), } 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 ef73edfaf5..03e8d57150 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/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 1f9dd8af8d..1f5f39dee7 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, 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 e3f84e9095..79d17746ef 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() } @@ -399,7 +408,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/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 7175913dad..05eb4c4d5f 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 24201aaf63..88c86a43d6 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 @@ -139,6 +140,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, @@ -227,17 +230,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.addSession(sessionData) @@ -253,6 +258,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 @@ -275,7 +295,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, 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 1d45c47470..2b5cac67ea 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,11 @@ 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, + userDisplayName = null, + userAvatarUrl = null, ) internal fun ExternalSession.toSessionData( @@ -55,4 +60,8 @@ internal fun ExternalSession.toSessionData( passphrase = passphrase, sessionPath = sessionPaths.fileDirectory.absolutePath, cachePath = sessionPaths.cacheDirectory.absolutePath, + position = 0, + lastUsageIndex = 0, + userDisplayName = null, + userAvatarUrl = null, ) 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 1cb56dcbeb..045b5d7772 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 = { _, _, _ -> }, + ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), ) = RustMatrixClientFactory( baseDirectory = baseDirectory, 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 99f165eafb..6eed2d0967 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 8225ce5ebf..3f53d9d5e2 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 @@ -42,6 +42,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 homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, private val closeResult: () -> Unit = {}, ) : Client(NoPointer) { @@ -79,7 +80,7 @@ class FakeFfiClient( } override suspend fun getProfile(userId: String): UserProfile { - return UserProfile(userId = userId, displayName = null, avatarUrl = null) + return getProfileResult(userId) } override suspend fun homeserverLoginDetails(): HomeserverLoginDetails { 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 87614304f3..d2dce3816c 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 @@ -42,6 +42,5 @@ class FakeFfiClientBuilder( override fun username(username: String) = this override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this - override suspend fun build() = buildResult() } 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 abd83d098f..fc464e9ee2 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 05257d2b0a..1e9b6953a8 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 3b56f28c5a..51017f0af0 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 e48e0c2e1e..48595452d2 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 e40424ca0e..7ec03a258e 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 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 c33c0d8861..90b0a0b7c7 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 position, to be able to order account. */ + val position: Long, + /** The index of the last date of session usage. */ + val lastUsageIndex: Long, + /** 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 50e9c6f787..9d9f143e15 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 loggedInStateFlow(): 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,9 +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) } diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index b9f96e1105..3ceb4076cc 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -36,7 +36,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 f74676427d..d6197d868d 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 @@ -34,7 +34,7 @@ class DatabaseSessionStore( private val sessionDataMutex = Mutex() override fun loggedInStateFlow(): Flow { - return database.sessionDataQueries.selectFirst() + return database.sessionDataQueries.selectLatest() .asFlow() .mapToOneOrNull(dispatchers.io) .map { @@ -51,7 +51,17 @@ class DatabaseSessionStore( override suspend fun addSession(sessionData: SessionData) { sessionDataMutex.withLock { - database.sessionDataQueries.insertSessionData(sessionData.toDbModel()) + val lastUsageIndex = getLastUsageIndex() + 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, + ) + .toDbModel() + ) } } @@ -65,18 +75,71 @@ 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, + position = result.position, + lastUsageIndex = result.lastUsageIndex, + 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 latestSession = getLatestSession() + if (latestSession?.userId == sessionId) { + // Already the latest session + return + } + val lastUsageIndex = latestSession?.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 lastUsageIndex of the session + database.sessionDataQueries.updateSession( + result.copy( + lastUsageIndex = lastUsageIndex + 1, + ).toDbModel() + ) + } + } + + private fun getLastUsageIndex(): Long { + return database.sessionDataQueries.selectLatest() + .executeAsOneOrNull() + ?.lastUsageIndex + ?: -1L + } + 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 8dbbad2b71..3b694c0124 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, + position = position, + lastUsageIndex = lastUsageIndex, + userDisplayName = userDisplayName, + userAvatarUrl = userAvatarUrl, ) } @@ -45,5 +49,9 @@ internal fun DbSessionData.toApiModel(): SessionData { passphrase = passphrase, sessionPath = sessionPath, cachePath = cachePath, + position = position, + lastUsageIndex = lastUsageIndex, + 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 = 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) @@ -53,6 +55,10 @@ class InMemorySessionStore( } } + 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 } } @@ -65,6 +71,10 @@ class InMemorySessionStore( return sessionDataListFlow.value.firstOrNull() } + override suspend fun setLatestSession(sessionId: String) { + setLatestSessionResult(sessionId) + } + override suspend fun removeSession(sessionId: String) { val currentList = sessionDataListFlow.value.toMutableList() currentList.removeAll { it.userId == sessionId } 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 afff40b6e1..61b5370813 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 @@ -18,7 +18,11 @@ fun aSessionData( cachePath: String = "/a/path/to/a/cache", accessToken: String = "anAccessToken", refreshToken: String? = "aRefreshToken", - ): SessionData { + position: Long = 0, + lastUsageIndex: Long = 0, + userDisplayName: String? = null, + userAvatarUrl: String? = null, +): SessionData { return SessionData( userId = sessionId, deviceId = deviceId, @@ -33,5 +37,9 @@ fun aSessionData( passphrase = null, sessionPath = sessionPath, cachePath = cachePath, + position = position, + lastUsageIndex = lastUsageIndex, + userDisplayName = userDisplayName, + userAvatarUrl = userAvatarUrl, ) } 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 088fb387da..0eab9b8e5a 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 c18a480899..893be607a0 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 fb20de1a92..69070298ec 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 77c38435c3..b98fcee970 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.Inject +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +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 +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 -@Inject +fun interface PushHistoryNavigator { + fun navigateTo(roomId: RoomId, eventId: EventId) +} + +@AssistedInject class PushHistoryPresenter( + @Assisted private val pushHistoryNavigator: PushHistoryNavigator, private val pushService: PushService, + matrixClient: MatrixClient, ) : Presenter { + @AssistedFactory + fun 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 fda9c6e479..b4b6d7f75e 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 da37700a93..11d9c509cc 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, + ), ) } @@ -48,12 +51,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 2cd2e6dc20..3193716d34 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 = { _, _, _ -> }, ) } 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 3604622f5c..858956488c 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) 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 313735b6e6..9c1cdee25c 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 5c98b2c21a..ada8b61f35 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, ) } } diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 2b6c531eda..d46c54e1c3 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -107,6 +107,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")) 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 d80a8288ad..04f0f6867a 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) @@ -80,8 +82,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 6e65303761..1e5a54fb26 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,9 @@ 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.observer.NoOpSessionObserver import io.element.android.services.analytics.impl.store.AnalyticsStore import io.element.android.services.analytics.impl.store.FakeAnalyticsStore @@ -167,7 +169,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, @@ -258,11 +260,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) 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 8a0e6d0966..234a9c30ff 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 @@ -62,6 +62,7 @@ class KonsistClassNameTest { .withAllParentsOf(PreviewParameterProvider::class) .withoutName( "AspectRatioProvider", + "LoginModeViewErrorProvider", "OverlapRatioProvider", "TextFileContentProvider", ) 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 9cbd2efdc4..082a106be2 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", 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 0000000000..dd337cac3b --- /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 0000000000..640aebb723 --- /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.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 0000000000..2e7635cfdb --- /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 0000000000..10ca16827e --- /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/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 0000000000..cdd0ab890c --- /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 0000000000..f45558ed75 --- /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 0000000000..caa492261c --- /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 0000000000..06ecd42cb4 --- /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 782a8aba82..aca3254639 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 d12ebf79be..ecd34d97af 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 3f96c2234e..1c61185e1d 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 499340fa1e..b3452cc94f 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.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 0000000000..17cefd666f --- /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 0000000000..815ac1db6d --- /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 0000000000..46a95f8bd7 --- /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 0000000000..8a250d9439 --- /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_114_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.avatar_Avatar_Avatars_114_en.png new file mode 100644 index 0000000000..9b1636f3bd --- /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 0000000000..73fb759161 --- /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 0000000000..73fbe1fcdf --- /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 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 0000000000..c77ea23f32 --- /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 0000000000..616133a868 --- /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