Announce multi-account support

Show a one-time bottom sheet on the home screen to announce that several
accounts can now be added (element-hq/element-meta#3349).

- Add `Announcement.MultiAccount`, which is never shown by default.
- AppMigration11 queues the announcement on application upgrade only,
  and marks it as shown if the user already has several accounts.
- HomePresenter shows the bottom sheet only when the announcement is
  pending, the MultiAccount feature flag is enabled and the user has a
  single account.
- The "Add account" button hides the sheet, marks the announcement as
  dismissed and starts the add account flow through the new
  `HomeEntryPoint.Callback.navigateToAddAccount()`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-05 17:01:02 +02:00
parent 2de0d78ced
commit c654bf0947
19 changed files with 444 additions and 6 deletions
@@ -13,4 +13,5 @@ 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
}
@@ -221,6 +221,7 @@ class HomeFlowNode(
homeState = state,
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onAddAccountClick = callback::navigateToAddAccount,
onStartChatClick = callback::navigateToCreateRoom,
onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@@ -19,12 +19,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
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.sync.SyncService
@@ -43,6 +47,8 @@ class HomePresenter(
private val homeSpacesPresenter: Presenter<HomeSpacesState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
private val featureFlagService: FeatureFlagService,
) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@@ -59,6 +65,16 @@ class HomePresenter(
}.collectAsState(initial = persistentListOf(matrixUser))
val isOnline by syncService.isOnline.collectAsState()
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val showMultiAccountAnnouncement by remember {
combine(
announcementService.announcementsToShowFlow(),
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount),
sessionStore.sessionsFlow(),
) { announcements, isMultiAccountEnabled, sessions ->
// Do not announce the feature to users who already have several accounts
isMultiAccountEnabled && sessions.size == 1 && announcements.contains(Announcement.MultiAccount)
}
}.collectAsState(false)
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
@@ -82,6 +98,9 @@ class HomePresenter(
is HomeEvent.SwitchToAccount -> coroutineState.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
}
@@ -95,6 +114,7 @@ class HomePresenter(
homeSpacesState = homeSpacesState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
eventSink = ::handleEvent,
)
}
@@ -28,6 +28,7 @@ data class HomeState(
val homeSpacesState: HomeSpacesState,
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val showMultiAccountAnnouncement: Boolean,
val eventSink: (HomeEvent) -> Unit,
) {
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
@@ -41,6 +41,9 @@ open class HomeStatePreviewParam : PreviewParameterProvider<HomeState> {
aHomeState(
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
),
aHomeState(
showMultiAccountAnnouncement = true,
),
) + RoomListStatePreviewParam().values.map {
aHomeState(roomListState = it)
} + aHomeState(
@@ -61,6 +64,7 @@ internal fun aHomeState(
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
showMultiAccountAnnouncement: Boolean = false,
eventSink: (HomeEvent) -> Unit = {}
) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -68,6 +72,7 @@ internal fun aHomeState(
hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
@@ -39,6 +39,7 @@ import androidx.compose.ui.zIndex
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.HomeTopBar
import io.element.android.features.home.impl.components.MultiAccountAnnouncementBottomSheet
import io.element.android.features.home.impl.components.RoomListContentView
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
@@ -76,6 +77,7 @@ fun HomeView(
homeState: HomeState,
onRoomClick: (RoomId, EventId?) -> Unit,
onSettingsClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit,
@@ -110,6 +112,13 @@ fun HomeView(
)
}
if (homeState.showMultiAccountAnnouncement) {
MultiAccountAnnouncementBottomSheet(
onDismiss = { homeState.eventSink(HomeEvent.DismissMultiAccountAnnouncement) },
onAddAccountClick = { if (firstThrottler.canHandle()) onAddAccountClick() },
)
}
leaveRoomView()
HomeScaffold(
@@ -366,6 +375,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
homeState = state,
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -386,6 +396,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
homeState = aHomeState(),
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2026 Element Creations 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.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
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
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
/**
* Bottom sheet announcing that several accounts can now be added.
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-43073
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MultiAccountAnnouncementBottomSheet(
onDismiss: () -> Unit,
onAddAccountClick: () -> Unit,
) {
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope()
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
scrollable = false,
) {
MultiAccountAnnouncementContent(
onAddAccountClick = {
coroutineScope.launch {
sheetState.hide()
onDismiss()
onAddAccountClick()
}
},
)
}
}
@Composable
private fun MultiAccountAnnouncementContent(
onAddAccountClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(all = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BigIcon(
style = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
)
Spacer(Modifier.height(16.dp))
Text(
modifier = Modifier
.padding(horizontal = 8.dp)
.semantics { heading() },
text = stringResource(R.string.screen_multi_account_announcement_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
val path = stringResource(CommonStrings.common_settings) + " > " + stringResource(CommonStrings.common_add_another_account)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = annotatedTextWithBold(
text = stringResource(R.string.screen_multi_account_announcement_description, path),
boldText = path,
),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(40.dp))
Button(
text = stringResource(R.string.screen_multi_account_announcement_action),
leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
modifier = Modifier.fillMaxWidth(),
onClick = onAddAccountClick,
)
Spacer(Modifier.height(16.dp))
}
}
@PreviewsDayNight
@Composable
internal fun MultiAccountAnnouncementContentPreview() = ElementPreview(fillMaxSize = true) {
MultiAccountAnnouncementBottomSheet(
onDismiss = {},
onAddAccountClick = {},
)
}
@@ -52,6 +52,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToEnterRecoveryKey() = lambdaError()
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToAddAccount() = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -9,12 +9,18 @@
package io.element.android.features.home.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.home.impl.spaces.aHomeSpacesState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter
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.MatrixClient
@@ -30,6 +36,8 @@ 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.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@@ -144,6 +152,95 @@ class HomePresenterTest {
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
}
}
@Test
fun `present - multi account announcement is shown when announced, feature enabled and a single account`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData()),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to true),
),
)
presenter.test {
assertThat(awaitItem().showMultiAccountAnnouncement).isFalse()
assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isTrue()
}
}
@Test
fun `present - multi account announcement is not shown when the feature is disabled`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData()),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to false),
),
)
presenter.test {
assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse()
}
}
@Test
fun `present - multi account announcement is not shown when there are several accounts`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = "@alice:server.org"),
aSessionData(sessionId = "@bob:server.org"),
),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to true),
),
)
presenter.test {
assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse()
}
}
@Test
fun `present - dismiss multi account announcement`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount),
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
)
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData()),
updateUserProfileResult = { _, _, _, _ -> },
),
announcementService = announcementService,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MultiAccount.key to true),
),
)
presenter.test {
val state = expectMostRecentItem()
assertThat(state.showMultiAccountAnnouncement).isTrue()
state.eventSink(HomeEvent.DismissMultiAccountAnnouncement)
announcementService.emitAnnouncementsToShow(emptyList())
assertThat(awaitItem().showMultiAccountAnnouncement).isFalse()
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.MultiAccount))
}
}
}
internal fun createHomePresenter(
@@ -154,6 +251,8 @@ internal fun createHomePresenter(
indicatorService: IndicatorService = FakeIndicatorService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
) = HomePresenter(
client = client,
syncService = syncService,
@@ -163,4 +262,6 @@ internal fun createHomePresenter(
homeSpacesPresenter = homeSpacesPresenter,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
sessionStore = sessionStore,
announcementService = announcementService,
featureFlagService = featureFlagService,
)
@@ -19,6 +19,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.HomeEvent
import io.element.android.features.home.impl.HomeView
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.aHomeState
@@ -259,6 +260,23 @@ class RoomListViewTest : RobolectricTest() {
)
)
}
@Test
fun `clicking on add account in the multi-account announcement emits the expected Event and invokes the callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<HomeEvent>()
ensureCalledOnce { callback ->
setRoomListView(
state = aRoomListState(),
showMultiAccountAnnouncement = true,
homeEventSink = eventsRecorder,
onAddAccountClick = callback,
)
clickOn(R.string.screen_multi_account_announcement_action)
// Wait for the bottom sheet to be hidden
waitForIdle()
}
eventsRecorder.assertSingle(HomeEvent.DismissMultiAccountAnnouncement)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
@@ -273,12 +291,20 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
showMultiAccountAnnouncement: Boolean = false,
homeEventSink: (HomeEvent) -> Unit = {},
) {
setSafeContent {
HomeView(
homeState = aHomeState(roomListState = state),
homeState = aHomeState(
roomListState = state,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
eventSink = homeEventSink,
),
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onAddAccountClick = onAddAccountClick,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick,