Handle account switch in RootFlowNode instead of in presenters

PreferencesRootPresenter and HomePresenter were updating the latest session
in the session store directly, relying on RootNavStateFlow to trigger the
navigation as a side effect. Changing the current session is app-level
navigation, so it is now forwarded through a new `navigateToSession` callback
up to RootFlowNode, which performs the session store update.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 10:37:15 +02:00
parent 9b3a9a0a2d
commit 6c1cc4e3f7
19 changed files with 74 additions and 52 deletions
@@ -14,6 +14,7 @@ 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
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint {
@@ -33,5 +34,6 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun navigateToRoomSettings(roomId: RoomId)
fun navigateToBugReport()
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
}
}
@@ -8,10 +8,7 @@
package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
data object DismissMultiAccountAnnouncement : HomeEvent
}
@@ -222,6 +222,7 @@ class HomeFlowNode(
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onAddAccountClick = callback::navigateToAddAccount,
onSwitchToAccount = callback::navigateToSession,
onStartChatClick = callback::navigateToCreateRoom,
onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@@ -126,9 +126,6 @@ class HomePresenter(
is HomeEvent.SelectHomeNavigationBarItem -> {
currentHomeNavigationBarItemOrdinal = event.item.ordinal
}
is HomeEvent.SwitchToAccount -> coroutineState.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
@@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
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.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@@ -78,6 +79,7 @@ fun HomeView(
onRoomClick: (RoomId, EventId?) -> Unit,
onSettingsClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSwitchToAccount: (SessionId) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit,
@@ -130,6 +132,7 @@ fun HomeView(
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
onSwitchToAccount = onSwitchToAccount,
)
if (state.globalSearchState.isEnabled) {
@@ -167,6 +170,7 @@ private fun HomeScaffold(
onStartChatClick: () -> Unit,
onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onSwitchToAccount: (SessionId) -> Unit,
modifier: Modifier = Modifier,
) {
fun onRoomClick(room: RoomListRoomSummary) {
@@ -213,9 +217,7 @@ private fun HomeScaffold(
},
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
onAccountSwitch = {
state.eventSink(HomeEvent.SwitchToAccount(it))
},
onAccountSwitch = onSwitchToAccount,
scrollBehavior = scrollBehavior,
displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState,
@@ -379,6 +381,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSwitchToAccount = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -400,6 +403,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSwitchToAccount = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -14,6 +14,7 @@ import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint
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.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -53,6 +54,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -305,6 +305,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onAddAccountClick = onAddAccountClick,
onSwitchToAccount = EnsureNeverCalledWithParam(),
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick,
@@ -16,6 +16,7 @@ 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 {
@@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
fun navigateToLinkNewDevice()
fun navigateToBugReport()
fun navigateToSecureBackup()
@@ -46,6 +46,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
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.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
@@ -141,6 +142,10 @@ class PreferencesFlowNode(
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
override fun navigateToAnalyticsSettings() {
backstack.push(NavTarget.AnalyticsSettings)
}
@@ -8,11 +8,8 @@
package io.element.android.features.preferences.impl.root
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
data object ToggleOtherAccountsExpanded : PreferencesRootEvent
data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent
}
@@ -24,6 +24,7 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
@ContributesNode(SessionScope::class)
@AssistedInject
@@ -35,6 +36,7 @@ class PreferencesRootNode(
) : Node(buildContext, plugins = plugins) {
interface Callback : PreferencesAccountCallback, Plugin {
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
fun navigateToAnalyticsSettings()
fun navigateToAbout()
fun navigateToDeveloperSettings()
@@ -72,6 +74,7 @@ class PreferencesRootNode(
modifier = modifier,
onBackClick = this::navigateUp,
onAddAccountClick = callback::navigateToAddAccount,
onSwitchToSession = callback::navigateToSession,
onOpenAnalytics = callback::navigateToAnalyticsSettings,
onOpenAbout = callback::navigateToAbout,
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
@@ -124,9 +124,6 @@ class PreferencesRootPresenter(
is PreferencesRootEvent.OnVersionInfoClick -> {
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
}
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch {
appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded)
}
@@ -65,6 +65,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -79,6 +80,7 @@ fun PreferencesRootView(
emojiPickerRenderer: EmojiPickerRenderer,
onBackClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSwitchToSession: (SessionId) -> Unit,
onOpenAnalytics: () -> Unit,
onOpenLockScreenSettings: () -> Unit,
onOpenAbout: () -> Unit,
@@ -124,6 +126,7 @@ fun PreferencesRootView(
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
onSwitchToSession = onSwitchToSession,
)
}
HorizontalDivider(
@@ -208,6 +211,7 @@ private fun ColumnScope.UserStatusSection(
private fun ColumnScope.MultiAccountSection(
state: PreferencesRootState,
onAddAccountClick: () -> Unit,
onSwitchToSession: (SessionId) -> Unit,
) {
HorizontalDivider(
thickness = 8.dp,
@@ -275,7 +279,7 @@ private fun ColumnScope.MultiAccountSection(
MatrixUserRow(
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
onSwitchToSession(matrixUser.userId)
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
@@ -427,6 +431,7 @@ private fun ContentToPreview(state: PreferencesRootState) {
emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = {},
onAddAccountClick = {},
onSwitchToSession = {},
onOpenAnalytics = {},
onOpenDeveloperSettings = {},
onOpenLocationSettings = {},
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
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.mediaviewer.test.FakeFileViewerEntryPoint
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
@@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest {
}
val callback = object : PreferencesEntryPoint.Callback {
override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
override fun navigateToLinkNewDevice() = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToSecureBackup() = lambdaError()
@@ -39,8 +39,6 @@ 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.consumeItemsUntilPredicate
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.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -179,24 +177,6 @@ class PreferencesRootPresenterTest {
}
}
@Test
fun `present - switch session invoke method on the session store`() = runTest {
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
),
setLatestSessionResult = setLatestSessionResult,
)
createPreferencesRootPresenter(sessionStore = sessionStore).test {
val loadedState = awaitFirstItem()
loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2))
setLatestSessionResult.assertions().isCalledOnce()
.with(value(A_SESSION_ID_2.value))
}
}
@Test
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
createPreferencesRootPresenter(
@@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -69,23 +70,25 @@ class PreferencesRootViewTest : RobolectricTest() {
}
@Test
fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnceWithParam(A_USER_ID_2) { callback ->
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
eventSink = eventsRecorder,
),
)
onNodeWithText("Bob").performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
onSwitchToSession = callback,
)
onNodeWithText("Bob").performClick()
}
}
@Test
@@ -395,6 +398,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
state: PreferencesRootState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(),
onOpenAnalytics: () -> Unit = EnsureNeverCalled(),
onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(),
onOpenAbout: () -> Unit = EnsureNeverCalled(),
@@ -418,6 +422,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = onBackClick,
onAddAccountClick = onAddAccountClick,
onSwitchToSession = onSwitchToSession,
onOpenAnalytics = onOpenAnalytics,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onOpenAbout = onOpenAbout,