From 2de0d78ced8637b054467ddfb4a6875b1346b106 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 5 Oct 2026 17:01:01 +0200 Subject: [PATCH 1/9] Import multi-account announcement strings from Localazy Add the `screen.multi_account_announcement.*` keys to the home module Localazy configuration and import the latest strings. Co-Authored-By: Claude Opus 5.5 (1M context) --- features/home/impl/src/main/res/values/localazy.xml | 3 +++ features/preferences/impl/src/main/res/values/localazy.xml | 1 + libraries/ui-strings/src/main/res/values/localazy.xml | 3 +++ tools/localazy/config.json | 1 + 4 files changed, 8 insertions(+) diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml index b3c14354f5..4f3420fa23 100644 --- a/features/home/impl/src/main/res/values/localazy.xml +++ b/features/home/impl/src/main/res/values/localazy.xml @@ -24,6 +24,9 @@ "%1$s (%2$s) invited you" "This is a one time process, thanks for waiting." "Setting up your account." + "Add account" + "Go to %1$s to sign into another account. Then you can easily switch between them." + "You can now add multiple accounts" "Create a new conversation or room" "Clear filters" "Get started by messaging someone." diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 6e70c9b685..c702ac62a3 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -110,6 +110,7 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" + "Add account" "Away" "Be right back" "Custom…" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 102c627d90..0eeb366d72 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -247,6 +247,7 @@ "Call started" "You declined a call" "Chat backup" + "Chats" "Code block" "Copied to clipboard" "Copyright" @@ -287,6 +288,7 @@ Reason: %1$s." "File saved" "File saved to Downloads" "Forward message" + "Forward to" "Frequently used" "Gallery" @@ -299,6 +301,7 @@ Reason: %1$s." "GIF" "Group call in progress" + "Rooms" "Image" "In reply to %1$s" "Install APK" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 40b4ab299e..e7f5f1e036 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -199,6 +199,7 @@ "screen\\.home\\..*", "screen_roomlist_.*", "screen\\.roomlist\\..*", + "screen\\.multi_account_announcement\\..*", "session_verification_banner_.*", "confirm_recovery_key_banner_.*", "banner\\.set_up_recovery\\..*", From c654bf09478e65821925c9070183460ced1a54cc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 5 Oct 2026 17:01:02 +0200 Subject: [PATCH 2/9] 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) --- .../android/appnav/LoggedInFlowNode.kt | 4 + .../features/announcement/api/Announcement.kt | 2 + .../impl/DefaultAnnouncementService.kt | 11 +- .../impl/store/DefaultAnnouncementStore.kt | 9 +- .../impl/DefaultAnnouncementServiceTest.kt | 20 +++ .../impl/store/InMemoryAnnouncementStore.kt | 4 + .../features/home/api/HomeEntryPoint.kt | 1 + .../android/features/home/impl/HomeEvent.kt | 1 + .../features/home/impl/HomeFlowNode.kt | 1 + .../features/home/impl/HomePresenter.kt | 20 +++ .../android/features/home/impl/HomeState.kt | 1 + .../home/impl/HomeStatePreviewParam.kt | 5 + .../android/features/home/impl/HomeView.kt | 11 ++ .../MultiAccountAnnouncementBottomSheet.kt | 128 ++++++++++++++++++ .../home/impl/DefaultHomeEntryPointTest.kt | 1 + .../features/home/impl/HomePresenterTest.kt | 101 ++++++++++++++ .../home/impl/roomlist/RoomListViewTest.kt | 28 +++- .../impl/migrations/AppMigration11.kt | 37 +++++ .../impl/migrations/AppMigration11Test.kt | 65 +++++++++ 19 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt create mode 100644 features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt create mode 100644 features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt 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 370c0db2f1..dce0911165 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -359,6 +359,10 @@ class LoggedInFlowNode( override fun navigateToBugReport() { callback.navigateToBugReport() } + + override fun navigateToAddAccount() { + callback.navigateToAddAccount() + } } homeEntryPoint.createNode( parentNode = this, diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index d743ae4cd6..69def559a1 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -17,4 +17,6 @@ sealed interface Announcement { } data object NewNotificationSound : Announcement + + data object MultiAccount : Announcement } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt index adb81db61a..29fe04bc9f 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -29,10 +29,11 @@ class DefaultAnnouncementService( ) : AnnouncementService { override suspend fun showAnnouncement(announcement: Announcement) { when (announcement) { - is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement) + is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement) Announcement.NewNotificationSound -> { announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) } + Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement) } } @@ -44,16 +45,20 @@ class DefaultAnnouncementService( return combine( flowOf(Unit), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), - ) { _, newNotificationSoundStatus -> + announcementStore.announcementStatusFlow(Announcement.MultiAccount), + ) { _, newNotificationSoundStatus, multiAccountStatus -> buildList { if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) } + if (multiAccountStatus == AnnouncementStatus.Show) { + add(Announcement.MultiAccount) + } } } } - private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) { + private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) { val currentValue = announcementStore.announcementStatusFlow(announcement).first() if (currentValue == AnnouncementStatus.NeverShown) { announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index 2093baa8b3..a075c331cd 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.map private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") +private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement") @ContributesBinding(AppScope::class) class DefaultAnnouncementStore( @@ -36,11 +37,14 @@ class DefaultAnnouncementStore( override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() // Default-status convention: Shown for one-shot intro/feature announcements that should - // NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted - // in by AppMigration08). + // NOT replay on first launch. + // - Fullscreen.Space is disabled + // - NewNotificationSound is opted in by AppMigration08 + // - MultiAccount is opted in by AppMigration11 val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown + Announcement.MultiAccount -> AnnouncementStatus.Shown } return store.data.map { prefs -> val ordinal = prefs[key] ?: defaultStatus.ordinal @@ -56,4 +60,5 @@ class DefaultAnnouncementStore( private fun Announcement.toKey() = when (this) { Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.NewNotificationSound -> newNotificationSoundKey + Announcement.MultiAccount -> multiAccountKey } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt index c72d147c1d..e140a0030d 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -51,6 +51,22 @@ class DefaultAnnouncementServiceTest { assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) } + @Test + fun `when showing MultiAccount announcement, announcement is set to show only if it was never shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.MultiAccount) + // Calling again showAnnouncement should not change the value + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown) + } + @Test fun `test announcementsToShowFlow`() = runTest { val announcementStore = InMemoryAnnouncementStore() @@ -61,7 +77,11 @@ class DefaultAnnouncementServiceTest { assertThat(awaitItem()).isEmpty() announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown) assertThat(awaitItem()).isEmpty() } } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt index ed6dfec850..9b5e762eb6 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt @@ -16,9 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow class InMemoryAnnouncementStore( initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, ) : AnnouncementStore { private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) + private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus) override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { announcement.toMutableStateFlow().value = status @@ -31,10 +33,12 @@ class InMemoryAnnouncementStore( override suspend fun reset() { fullScreenAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown + multiAccountAnnouncement.value = AnnouncementStatus.NeverShown } private fun Announcement.toMutableStateFlow() = when (this) { is Announcement.Fullscreen -> fullScreenAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement + Announcement.MultiAccount -> multiAccountAnnouncement } } diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 1c6a0a906e..486a8bafce 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -32,5 +32,6 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToEnterRecoveryKey() fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() + fun navigateToAddAccount() } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt index 7d2bb09d30..6d1eec3dff 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt @@ -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 } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 74dd6e441d..a481171e97 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -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, 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 f0bcedbb43..7c43d6a90c 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 @@ -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, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, + private val announcementService: AnnouncementService, + private val featureFlagService: FeatureFlagService, ) : Presenter { 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, ) } 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 462084aefb..a49d3876e9 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 @@ -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 diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index f39942275a..a17e678d25 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -41,6 +41,9 @@ open class HomeStatePreviewParam : PreviewParameterProvider { 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, 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 bcc3229598..6ff31a7c12 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 @@ -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 = {}, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt new file mode 100644 index 0000000000..f5ffaeb41f --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt @@ -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 = {}, + ) +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index cf9336b6ea..643de32ecb 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -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, 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 a5553352db..2f6971da8b 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 @@ -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 { } + 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 = 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, ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index bd18cd903e..3cff7ca28b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -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() + 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.setRoomListView( @@ -273,12 +291,20 @@ private fun AndroidComposeUiTest.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, diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt new file mode 100644 index 0000000000..97bc5d24a4 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt @@ -0,0 +1,37 @@ +/* + * 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.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.libraries.sessionstorage.api.SessionStore + +/** + * Ensure the multi-account announcement is displayed, but only on application upgrade, + * and only if the user does not already have several accounts. + */ +@ContributesIntoSet(AppScope::class) +class AppMigration11( + private val sessionStore: SessionStore, + private val announcementService: AnnouncementService, +) : AppMigration { + override val order: Int = 11 + + override suspend fun migrate(isFreshInstall: Boolean) { + if (isFreshInstall) return + + if (sessionStore.getAllSessions().size > 1) { + // The user already uses several accounts, no need to announce the feature + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } else { + announcementService.showAnnouncement(Announcement.MultiAccount) + } + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt new file mode 100644 index 0000000000..08bfd5b2ec --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt @@ -0,0 +1,65 @@ +/* + * 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.migration.impl.migrations + +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AppMigration11Test { + @Test + fun `migration on fresh install should not invoke the AnnouncementService`() = runTest { + val service = FakeAnnouncementService() + val migration = AppMigration11( + sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())), + announcementService = service, + ) + // FakeAnnouncementService lambdas throw by default + migration.migrate(isFreshInstall = true) + } + + @Test + fun `migration on upgrade with a single account should show the announcement`() = runTest { + val showAnnouncementResult = lambdaRecorder { } + val service = FakeAnnouncementService( + showAnnouncementResult = showAnnouncementResult, + ) + val migration = AppMigration11( + sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())), + announcementService = service, + ) + migration.migrate(isFreshInstall = false) + showAnnouncementResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + + @Test + fun `migration on upgrade with several accounts should mark the announcement as dismissed`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val service = FakeAnnouncementService( + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) + val migration = AppMigration11( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = "@alice:server.org"), + aSessionData(sessionId = "@bob:server.org"), + ) + ), + announcementService = service, + ) + migration.migrate(isFreshInstall = false) + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } +} From 4e25ecd34cf9f0e49671cfaee56585e3f82d91c7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 5 Oct 2026 18:01:54 +0200 Subject: [PATCH 3/9] Dismiss the multi-account announcement when the user has several accounts If the announcement was queued at upgrade while the user had a single account, and the user then added another account without seeing it, the announcement stayed pending and was shown later if the user removed an account. Mark it as dismissed as soon as several accounts are detected. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/features/home/impl/HomePresenter.kt | 15 +++++++++++++++ .../features/home/impl/HomePresenterTest.kt | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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 7c43d6a90c..95c98a4aec 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 @@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Inject @@ -75,6 +76,20 @@ class HomePresenter( isMultiAccountEnabled && sessions.size == 1 && announcements.contains(Announcement.MultiAccount) } }.collectAsState(false) + LaunchedEffect(Unit) { + // The user already uses several accounts, so the feature does not need to be announced anymore, + // even if they later remove accounts. + combine( + announcementService.announcementsToShowFlow(), + sessionStore.sessionsFlow(), + ) { announcements, sessions -> + announcements.contains(Announcement.MultiAccount) && sessions.size > 1 + } + .filter { it } + .collect { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } val roomListState = roomListPresenter.present() val homeSpacesState = homeSpacesPresenter.present() var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } 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 2f6971da8b..d4b03b68c7 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 @@ -193,7 +193,8 @@ class HomePresenterTest { } @Test - fun `present - multi account announcement is not shown when there are several accounts`() = runTest { + fun `present - multi account announcement is not shown and is dismissed when there are several accounts`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } val presenter = createHomePresenter( sessionStore = InMemorySessionStore( initialList = listOf( @@ -204,6 +205,7 @@ class HomePresenterTest { ), announcementService = FakeAnnouncementService( initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, ), featureFlagService = FakeFeatureFlagService( initialState = mapOf(FeatureFlags.MultiAccount.key to true), @@ -211,6 +213,8 @@ class HomePresenterTest { ) presenter.test { assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) } } From 4d183ffeaf33ebf1304550f4cf07cebbe69909aa Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Oct 2026 09:04:30 +0200 Subject: [PATCH 4/9] Add consign when AI adds new states for screenshot. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a4a73ca965..dc34ebd571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ PRs must meet these rules. - Create previews for **all main states** of a Composable. - Use `@PreviewsDayNight` for consistency. -- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. +- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. Always add new states to existing providers at the end of the sequence. - Wrap previews in `ElementPreview { ... }`. - When writing tests, never try to record the screenshots, the CI will do it. From d91c22d1b29e67881c398b6be2021adf6a59bdee Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Oct 2026 09:10:47 +0200 Subject: [PATCH 5/9] Move new states at the end of the sequence --- .../android/features/home/impl/HomeStatePreviewParam.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index a17e678d25..f86c32c532 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -41,9 +41,6 @@ open class HomeStatePreviewParam : PreviewParameterProvider { aHomeState( currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces, ), - aHomeState( - showMultiAccountAnnouncement = true, - ), ) + RoomListStatePreviewParam().values.map { aHomeState(roomListState = it) } + aHomeState( @@ -51,6 +48,8 @@ open class HomeStatePreviewParam : PreviewParameterProvider { homeSpacesState = aHomeSpacesState( spaceRooms = emptyList(), ), + ) + aHomeState( + showMultiAccountAnnouncement = true, ) } From 86e72ac11e19d946ca6ccd7a655f369471af5d1d Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 6 Oct 2026 07:38:09 +0000 Subject: [PATCH 6/9] Update screenshots --- ...mpl.components_MultiAccountAnnouncementContent_Day_0_en.png | 3 +++ ...l.components_MultiAccountAnnouncementContent_Night_0_en.png | 3 +++ .../snapshots/images/features.home.impl_HomeView_Day_17_en.png | 3 +++ .../images/features.home.impl_HomeView_Night_17_en.png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Day_0_en.png new file mode 100644 index 0000000000..747c2f7197 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69f16135c4bfcddac589989018f053f3a09b6111a55370915ccaf2e8837c89e7 +size 33204 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Night_0_en.png new file mode 100644 index 0000000000..1d11c81145 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cf546530844f35beb6efa6f1b6874dcf95d3079889a1ea21702d14266ca81e0 +size 31449 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png new file mode 100644 index 0000000000..342d1ab6ff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5c44832e3d4cbb637d48abc19174a66bdff343de3e60aa94c3d6bfaac44e78d +size 62363 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png new file mode 100644 index 0000000000..a2a191b30f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_17_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:513e23929e81026a24cb2d964fd8a65ba4998ac715021d480d7e7211d6a48d0a +size 59148 From 72f4c4f8972d8106b3ec68eee159e01ce347d910 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Oct 2026 09:33:33 +0200 Subject: [PATCH 7/9] Fix multi-account announcement never being displayed --- .../impl/store/DefaultAnnouncementStore.kt | 4 ++-- .../migration/impl/migrations/AppMigration11.kt | 7 +++---- .../migration/impl/migrations/AppMigration11Test.kt | 10 +++++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index a075c331cd..c51fc11f26 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -40,11 +40,11 @@ class DefaultAnnouncementStore( // NOT replay on first launch. // - Fullscreen.Space is disabled // - NewNotificationSound is opted in by AppMigration08 - // - MultiAccount is opted in by AppMigration11 + // - MultiAccount is NeverShown by default, AppMigration11 sets it to Show or Shown. val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown - Announcement.MultiAccount -> AnnouncementStatus.Shown + Announcement.MultiAccount -> AnnouncementStatus.NeverShown } return store.data.map { prefs -> val ordinal = prefs[key] ?: defaultStatus.ordinal diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt index 97bc5d24a4..71fc6a1d52 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.sessionstorage.api.SessionStore /** * Ensure the multi-account announcement is displayed, but only on application upgrade, * and only if the user does not already have several accounts. + * In the other cases, mark the announcement as already shown. */ @ContributesIntoSet(AppScope::class) class AppMigration11( @@ -25,10 +26,8 @@ class AppMigration11( override val order: Int = 11 override suspend fun migrate(isFreshInstall: Boolean) { - if (isFreshInstall) return - - if (sessionStore.getAllSessions().size > 1) { - // The user already uses several accounts, no need to announce the feature + if (isFreshInstall || sessionStore.getAllSessions().size > 1) { + // New users, or users who already have several accounts, do not need to be informed about the feature announcementService.onAnnouncementDismissed(Announcement.MultiAccount) } else { announcementService.showAnnouncement(Announcement.MultiAccount) diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt index 08bfd5b2ec..b8d45ace46 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt @@ -18,14 +18,18 @@ import org.junit.Test class AppMigration11Test { @Test - fun `migration on fresh install should not invoke the AnnouncementService`() = runTest { - val service = FakeAnnouncementService() + fun `migration on fresh install should mark the announcement as dismissed`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val service = FakeAnnouncementService( + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) val migration = AppMigration11( sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())), announcementService = service, ) - // FakeAnnouncementService lambdas throw by default migration.migrate(isFreshInstall = true) + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) } @Test From ddcd01a2bfea592d750ecde99ce6c89721e50030 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Oct 2026 09:46:28 +0200 Subject: [PATCH 8/9] Follow naming convention --- .../home/impl/components/MultiAccountAnnouncementBottomSheet.kt | 2 +- ...components_MultiAccountAnnouncementBottomSheet_Day_0_en.png} | 0 ...mponents_MultiAccountAnnouncementBottomSheet_Night_0_en.png} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename tests/uitests/src/test/snapshots/images/{features.home.impl.components_MultiAccountAnnouncementContent_Day_0_en.png => features.home.impl.components_MultiAccountAnnouncementBottomSheet_Day_0_en.png} (100%) rename tests/uitests/src/test/snapshots/images/{features.home.impl.components_MultiAccountAnnouncementContent_Night_0_en.png => features.home.impl.components_MultiAccountAnnouncementBottomSheet_Night_0_en.png} (100%) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt index f5ffaeb41f..cec1dbf3a0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt @@ -120,7 +120,7 @@ private fun MultiAccountAnnouncementContent( @PreviewsDayNight @Composable -internal fun MultiAccountAnnouncementContentPreview() = ElementPreview(fillMaxSize = true) { +internal fun MultiAccountAnnouncementBottomSheetPreview() = ElementPreview(fillMaxSize = true) { MultiAccountAnnouncementBottomSheet( onDismiss = {}, onAddAccountClick = {}, diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Day_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Day_0_en.png rename to tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Day_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Night_0_en.png similarity index 100% rename from tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementContent_Night_0_en.png rename to tests/uitests/src/test/snapshots/images/features.home.impl.components_MultiAccountAnnouncementBottomSheet_Night_0_en.png From b1f4178100ee3c908a2eff732b88aa66d240e727 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Oct 2026 10:30:21 +0200 Subject: [PATCH 9/9] Replace AppMigration11 by a dismissal when entering the login flow AppMigration11 was run once, in the first release containing it, whatever the state of the MultiAccount feature flag. Users installing the app while the feature flag was disabled were considered as new users and would never see the announcement once the feature flag got enabled. The multi-account announcement is now pending by default (NeverShown), and is marked as shown when the user enters the login flow while the feature is enabled, either for the first time or to add another account. Users who were already logged in before the feature became available will see the announcement. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/appnav/NotLoggedInFlowNode.kt | 15 ++++ .../android/appnav/RootFlowNodeTest.kt | 2 + .../impl/DefaultAnnouncementService.kt | 4 +- .../impl/store/DefaultAnnouncementStore.kt | 3 +- .../impl/DefaultAnnouncementServiceTest.kt | 6 +- .../impl/migrations/AppMigration11.kt | 36 ---------- .../impl/migrations/AppMigration11Test.kt | 69 ------------------- 7 files changed, 26 insertions(+), 109 deletions(-) delete mode 100644 features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt delete mode 100644 features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index 080f3c1d77..8b8b323aab 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -13,6 +13,7 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import coil3.SingletonImageLoader import coil3.annotation.DelicateCoilApi import com.bumble.appyx.core.lifecycle.subscribe @@ -24,6 +25,8 @@ 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.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.libraries.architecture.BackstackView @@ -33,8 +36,11 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ScreenOrientation +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @ContributesNode(AppScope::class) @@ -45,6 +51,8 @@ class NotLoggedInFlowNode( private val loginEntryPoint: LoginEntryPoint, private val imageLoaderHolder: ImageLoaderHolder, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, + private val featureFlagService: FeatureFlagService, + private val announcementService: AnnouncementService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, @@ -68,6 +76,13 @@ class NotLoggedInFlowNode( override fun onBuilt() { super.onBuilt() analyticsColdStartWatcher.whenLoggingIn() + lifecycleScope.launch { + // Users who enter the login flow while the feature is available, either for the first time or to add + // another account, do not need to be informed about the multi-account feature. + if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } lifecycle.subscribe( onResume = { SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index cc23aea626..dd0a10c2ea 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -141,6 +141,8 @@ class RootFlowNodeTest : RobolectricTest() { loginEntryPoint = loginEntryPoint, imageLoaderHolder = FakeImageLoaderHolder(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + featureFlagService = FakeFeatureFlagService(), + announcementService = FakeAnnouncementService(), ) } ) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt index 29fe04bc9f..3c36989571 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -51,7 +51,9 @@ class DefaultAnnouncementService( if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) } - if (multiAccountStatus == AnnouncementStatus.Show) { + // The multi-account announcement is pending until it is dismissed. Users who were already + // logged in when the feature became available still have the default NeverShown status. + if (multiAccountStatus != AnnouncementStatus.Shown) { add(Announcement.MultiAccount) } } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index c51fc11f26..2235285bcf 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -40,7 +40,8 @@ class DefaultAnnouncementStore( // NOT replay on first launch. // - Fullscreen.Space is disabled // - NewNotificationSound is opted in by AppMigration08 - // - MultiAccount is NeverShown by default, AppMigration11 sets it to Show or Shown. + // - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters + // the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement. val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt index e140a0030d..18442b362d 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -74,10 +74,12 @@ class DefaultAnnouncementServiceTest { announcementStore = announcementStore, ) sut.announcementsToShowFlow().test { - assertThat(awaitItem()).isEmpty() + // MultiAccount is pending by default + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) - assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show) + // Still pending assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt deleted file mode 100644 index 71fc6a1d52..0000000000 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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.migration.impl.migrations - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoSet -import io.element.android.features.announcement.api.Announcement -import io.element.android.features.announcement.api.AnnouncementService -import io.element.android.libraries.sessionstorage.api.SessionStore - -/** - * Ensure the multi-account announcement is displayed, but only on application upgrade, - * and only if the user does not already have several accounts. - * In the other cases, mark the announcement as already shown. - */ -@ContributesIntoSet(AppScope::class) -class AppMigration11( - private val sessionStore: SessionStore, - private val announcementService: AnnouncementService, -) : AppMigration { - override val order: Int = 11 - - override suspend fun migrate(isFreshInstall: Boolean) { - if (isFreshInstall || sessionStore.getAllSessions().size > 1) { - // New users, or users who already have several accounts, do not need to be informed about the feature - announcementService.onAnnouncementDismissed(Announcement.MultiAccount) - } else { - announcementService.showAnnouncement(Announcement.MultiAccount) - } - } -} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt deleted file mode 100644 index b8d45ace46..0000000000 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration11Test.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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.migration.impl.migrations - -import io.element.android.features.announcement.api.Announcement -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService -import io.element.android.libraries.sessionstorage.test.InMemorySessionStore -import io.element.android.libraries.sessionstorage.test.aSessionData -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class AppMigration11Test { - @Test - fun `migration on fresh install should mark the announcement as dismissed`() = runTest { - val onAnnouncementDismissedResult = lambdaRecorder { } - val service = FakeAnnouncementService( - onAnnouncementDismissedResult = onAnnouncementDismissedResult, - ) - val migration = AppMigration11( - sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())), - announcementService = service, - ) - migration.migrate(isFreshInstall = true) - onAnnouncementDismissedResult.assertions().isCalledOnce() - .with(value(Announcement.MultiAccount)) - } - - @Test - fun `migration on upgrade with a single account should show the announcement`() = runTest { - val showAnnouncementResult = lambdaRecorder { } - val service = FakeAnnouncementService( - showAnnouncementResult = showAnnouncementResult, - ) - val migration = AppMigration11( - sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())), - announcementService = service, - ) - migration.migrate(isFreshInstall = false) - showAnnouncementResult.assertions().isCalledOnce() - .with(value(Announcement.MultiAccount)) - } - - @Test - fun `migration on upgrade with several accounts should mark the announcement as dismissed`() = runTest { - val onAnnouncementDismissedResult = lambdaRecorder { } - val service = FakeAnnouncementService( - onAnnouncementDismissedResult = onAnnouncementDismissedResult, - ) - val migration = AppMigration11( - sessionStore = InMemorySessionStore( - initialList = listOf( - aSessionData(sessionId = "@alice:server.org"), - aSessionData(sessionId = "@bob:server.org"), - ) - ), - announcementService = service, - ) - migration.migrate(isFreshInstall = false) - onAnnouncementDismissedResult.assertions().isCalledOnce() - .with(value(Announcement.MultiAccount)) - } -}