Skip to content
Open
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
16a7fb3
Multi account - Do not reset analytics store on sign out.
bmarty Jul 3, 2024
d487372
Multi accounts - first implementation.
bmarty Aug 26, 2025
8ebfadd
Multi accounts - Prevent user from logging twice with the same account
bmarty Aug 27, 2025
0859fc3
Multi accounts - ignore automatic GoBack in case of error.
bmarty Aug 27, 2025
f51b392
Multi accounts - update first view when adding an account.
bmarty Aug 27, 2025
8ea5d0e
Rename method storeData to addSession.
bmarty Aug 27, 2025
e9987ef
Multi accounts - handle account switch when coming from a notification
bmarty Aug 27, 2025
3355446
Multi accounts - handle login link when there is already an account.
bmarty Aug 28, 2025
00d06f4
Multi accounts - handle click on push history for not current account.
bmarty Aug 28, 2025
9fa25c5
Multi accounts - improve layout and add preview.
bmarty Aug 28, 2025
d2130bc
Add accountselect modules
bmarty Aug 28, 2025
b545f1e
Multi accounts - incoming share with account selection
bmarty Aug 28, 2025
77f0aec
Multi accounts - check the feature flag before allowing login using l…
bmarty Aug 28, 2025
4dc81ed
Multi accounts - swipe on account icon
bmarty Aug 29, 2025
a2bac35
Cleanup
bmarty Aug 29, 2025
b80ef4b
Multi accounts - fix other implementation of SessionStore
bmarty Sep 1, 2025
236bfa6
Multi accounts - fix PreferencesRootPresenterTest
bmarty Sep 1, 2025
38a0dc9
Multi accounts - Add test on AccountSelectPresenter
bmarty Sep 1, 2025
303927b
Multi accounts - Fix test on HomePresenter - WIP
bmarty Sep 1, 2025
3dc268f
Update database to be able to sort accounts by creation date.
bmarty Sep 4, 2025
06e3925
Add unit test on takeCurrentUserWithNeighbors
bmarty Sep 4, 2025
3dda8ac
Fix test and improve code.
bmarty Sep 4, 2025
29154cf
Add exception
bmarty Sep 4, 2025
0798c3a
Multi accounts - handle permalink
bmarty Sep 4, 2025
e3a2c62
Code quality
bmarty Sep 4, 2025
718cd5b
Multi accounts - localization
bmarty Sep 4, 2025
462491c
Fix issue after rebase on develop
bmarty Sep 4, 2025
5b9e233
Fix issue after rebase on develop
bmarty Sep 5, 2025
cabaf34
Fix tests
bmarty Sep 5, 2025
7e6af0f
Fix tests
bmarty Sep 5, 2025
6a954bf
Fix tests
bmarty Sep 5, 2025
b9b22c2
Fix tests
bmarty Sep 5, 2025
18d3d5b
Update Multi accounts flag details.
bmarty Sep 5, 2025
72113cd
Add missing test on DatabaseSessionStore
bmarty Sep 5, 2025
97aff0f
Add missing preview on LoginModeView
bmarty Sep 5, 2025
62e4e58
Remove dead code.
bmarty Sep 5, 2025
540f8d6
Add missing preview on PushHistoryView
bmarty Sep 5, 2025
4299e12
Document API.
bmarty Sep 5, 2025
b2df76e
Rename API and update test.
bmarty Sep 5, 2025
40718cd
Remove MatrixAuthenticationService.loggedInStateFlow()
bmarty Sep 5, 2025
dd677e6
Update screenshots
ElementBot Sep 5, 2025
7f822cd
Remove unused import
bmarty Sep 5, 2025
9ef6e8e
Add exception
bmarty Sep 5, 2025
614ea0d
Fix compilation issue after rebase on develop.
bmarty Sep 5, 2025
3a46fec
Update screenshots
ElementBot Sep 5, 2025
866a17d
Merge branch 'develop' into feature/bma/_poc/multiAccounts3
bmarty Sep 11, 2025
5e85065
Fix test
bmarty Sep 12, 2025
2d77a63
Avoid calling getLatestSession() twice
bmarty Sep 17, 2025
5d0db44
Rename `matrixUserAndNeighbors` to `currentUserAndNeighbors`
bmarty Sep 17, 2025
b334f27
Extract code to its own class.
bmarty Sep 17, 2025
2ec6b2c
Add comment to clarify the code.
bmarty Sep 17, 2025
6baf08b
Init current user profile with what we now have in the database.
bmarty Sep 17, 2025
a84e2ff
Let the RustMatrixClient update the profile in the session database
bmarty Sep 17, 2025
e942e90
Fix test.
bmarty Sep 17, 2025
823cd97
When logging out from Pin code screen, logout from all the sessions.
bmarty Sep 18, 2025
097fc98
Make PushData.clientSecret mandatory.
bmarty Sep 18, 2025
7059c23
Change test in RustMatrixAuthenticationServiceTest
bmarty Sep 18, 2025
f589dd2
Do not use MatrixAuthenticationService in RootFlowNode, only use Sess…
bmarty Sep 18, 2025
3fd97c1
Remove MatrixAuthenticationService.getLatestSessionId()
bmarty Sep 18, 2025
c3e88d6
Merge branch 'develop' into feature/bma/_poc/multiAccounts3
bmarty Sep 19, 2025
81e7911
Fix compilation issue after merging develop
bmarty Sep 19, 2025
b8c441f
Add test on DefaultAccountSelectEntryPoint
bmarty Sep 19, 2025
b3f3f52
Fix compilation issue after merging develop
bmarty Sep 19, 2025
9c698ff
Introduce LoggedInAccountSwitcherNode, to improve animation when swit…
bmarty Sep 19, 2025
883b1f3
Rename Node to follow naming convention.
bmarty Sep 19, 2025
e409630
Fix navigation issue after login.
bmarty Sep 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions appnav/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class LoggedInAppScopeFlowNode(
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onOpenBugReport()
fun onAddAccount()
}

@Parcelize
Expand All @@ -83,6 +84,10 @@ class LoggedInAppScopeFlowNode(
override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}

override fun onAddAccount() {
plugins<Callback>().forEach { it.onAddAccount() }
}
}
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
Expand Down Expand Up @@ -139,6 +138,7 @@ class LoggedInFlowNode(
) {
interface Callback : Plugin {
fun onOpenBugReport()
fun onAddAccount()
}

private val loggedInFlowProcessor = LoggedInEventProcessor(
Expand Down Expand Up @@ -395,6 +395,10 @@ class LoggedInFlowNode(
}
is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback {
override fun onAddAccount() {
plugins<Callback>().forEach { it.onAddAccount() }
}

override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
Expand All @@ -407,11 +411,7 @@ class LoggedInFlowNode(
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
}

override fun navigateTo(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
// We do not check the sessionId, but it will have to be done at some point (multi account)
if (sessionId != matrixClient.sessionId) {
Timber.e("SessionId mismatch, expected ${matrixClient.sessionId} but got $sessionId")
}
override fun navigateTo(roomId: RoomId, eventId: EventId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Messages(eventId)))
}
}
Expand Down
160 changes: 129 additions & 31 deletions appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,28 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber

Expand All @@ -73,9 +78,12 @@ class RootFlowNode(
private val bugReportEntryPoint: BugReportEntryPoint,
private val viewFolderEntryPoint: ViewFolderEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint,
private val accountSelectEntryPoint: AccountSelectEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
private val bugReporter: BugReporter,
private val sessionStore: SessionStore,
private val featureFlagService: FeatureFlagService,
) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.SplashScreen,
Expand Down Expand Up @@ -182,6 +190,13 @@ class RootFlowNode(
@Parcelize
data object SplashScreen : NavTarget

@Parcelize
data class AccountSelect(
val currentSessionId: SessionId,
val intent: Intent?,
val permalinkData: PermalinkData?,
) : NavTarget

@Parcelize
data class NotLoggedInFlow(
val params: LoginParams?
Expand Down Expand Up @@ -218,6 +233,10 @@ class RootFlowNode(
override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport)
}

override fun onAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
}
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
}
Expand Down Expand Up @@ -272,6 +291,34 @@ class RootFlowNode(
.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()
}
}
}

Expand All @@ -293,19 +340,29 @@ class RootFlowNode(
}

private suspend fun onLoginLink(params: LoginParams) {
// Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
switchToNotLoggedInFlow(params)
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
// Is there a session already?
val sessions = sessionStore.getAllSessions()
if (sessions.isNotEmpty()) {
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")
}
}

Expand All @@ -316,32 +373,73 @@ 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 = authenticationService.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
switchToNotLoggedInFlow(null)
} else {
// wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId)
when (permalinkData) {
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
else -> {
if (sessionStore.getAllSessions().size > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
intent = null,
permalinkData = permalinkData,
)
)
}
is PermalinkData.UserLink -> {
attachUser(permalinkData.userId)
} else {
// Only one account, directly attach the room or the user node.
loggedInFlowNode.attachPermalinkData(permalinkData)
}
}
}
}
}

private suspend fun LoggedInFlowNode.attachPermalinkData(permalinkData: PermalinkData) {
when (permalinkData) {
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
is PermalinkData.RoomLink -> {
attachRoom(
roomIdOrAlias = permalinkData.roomIdOrAlias,
trigger = JoinedRoom.Trigger.MobilePermalink,
serverNames = permalinkData.viaParameters,
eventId = permalinkData.eventId,
clearBackstack = true
)
}
is PermalinkData.UserLink -> {
attachUser(permalinkData.userId)
}
}
}

private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Expand All @@ -359,11 +457,11 @@ class RootFlowNode(
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<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
navTarget is NavTarget.LoggedInFlow && (sessionId == null || navTarget.sessionId == sessionId)
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
}
.attachSession()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
Expand All @@ -28,7 +28,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact
*/
@Inject
class RootNavStateFlowFactory(
private val authenticationService: MatrixAuthenticationService,
private val sessionStore: SessionStore,
private val cacheService: CacheService,
private val matrixSessionCache: MatrixSessionCache,
private val imageLoaderHolder: ImageLoaderHolder,
Expand All @@ -39,7 +39,7 @@ class RootNavStateFlowFactory(
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
return combine(
cacheIndexFlow(savedStateMap),
authenticationService.loggedInStateFlow(),
sessionStore.loggedInStateFlow(),
) { cacheIndex, loggedInState ->
RootNavState(
cacheIndex = cacheIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -120,7 +120,7 @@ class IntentResolverTest {
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Oidc(
oidcAction = OidcAction.GoBack
oidcAction = OidcAction.GoBack()
)
)
}
Expand Down
1 change: 1 addition & 0 deletions features/home/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading