From a786f6a5e9eb71248e3fd10c00bb37b94673892b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Oct 2025 09:45:44 +0200 Subject: [PATCH 1/6] Improve AnnouncementService. --- .../features/announcement/api/Announcement.kt | 1 + .../announcement/api/AnnouncementService.kt | 8 +++++ .../impl/AnnouncementPresenter.kt | 5 +-- .../impl/DefaultAnnouncementService.kt | 31 +++++++++++++++++-- .../impl/spaces/SpaceAnnouncementPresenter.kt | 5 +-- .../impl/store/AnnouncementStore.kt | 13 ++++++-- .../impl/store/DefaultAnnouncementStore.kt | 26 ++++++++++++---- .../impl/AnnouncementPresenterTest.kt | 5 +-- .../impl/DefaultAnnouncementServiceTest.kt | 8 ++--- .../spaces/SpaceAnnouncementPresenterTest.kt | 5 +-- .../impl/store/InMemoryAnnouncementStore.kt | 24 +++++++++----- .../test/logs/FakeAnnouncementService.kt | 19 ++++++++++++ .../home/impl/roomlist/RoomListPresenter.kt | 11 +++++-- .../impl/roomlist/RoomListPresenterTest.kt | 23 +++++++++----- features/migration/impl/build.gradle.kts | 2 ++ .../impl/migrations/AppMigration08.kt | 7 +++-- .../impl/migrations/AppMigration08Test.kt | 30 +++++++++++------- .../api/store/AppPreferencesStore.kt | 3 -- .../impl/store/DefaultAppPreferencesStore.kt | 14 --------- .../test/InMemoryAppPreferencesStore.kt | 10 ------ 20 files changed, 169 insertions(+), 81 deletions(-) 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 96fd738903..21b83e7449 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 @@ -9,4 +9,5 @@ package io.element.android.features.announcement.api enum class Announcement { Space, + NewNotificationSound, } diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt index 62944d727e..a98c0af199 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt @@ -9,10 +9,18 @@ package io.element.android.features.announcement.api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.Flow interface AnnouncementService { suspend fun showAnnouncement(announcement: Announcement) + suspend fun onAnnouncementDismissed(announcement: Announcement) + + fun announcementsToShowFlow(): Flow> + + /** + * Use this composable to render the announcement UI in Fullscreen. + */ @Composable fun Render( modifier: Modifier, diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt index 76746bf44e..9600dd7229 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.libraries.architecture.Presenter import kotlinx.coroutines.flow.map @@ -23,8 +24,8 @@ class AnnouncementPresenter( @Composable override fun present(): AnnouncementState { val showSpaceAnnouncement by remember { - announcementStore.spaceAnnouncementFlow().map { - it == AnnouncementStore.SpaceAnnouncement.Show + announcementStore.announcementStateFlow(Announcement.Space).map { + it == AnnouncementStore.AnnouncementStatus.Show } }.collectAsState(false) return AnnouncementState( 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 e9b6310544..a092025bdc 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 @@ -23,6 +23,8 @@ import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementSta import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementView import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first @ContributesBinding(AppScope::class) @@ -35,13 +37,36 @@ class DefaultAnnouncementService( override suspend fun showAnnouncement(announcement: Announcement) { when (announcement) { Announcement.Space -> showSpaceAnnouncement() + Announcement.NewNotificationSound -> { + announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStore.AnnouncementStatus.Show) + } + } + } + + override suspend fun onAnnouncementDismissed(announcement: Announcement) { + announcementStore.setAnnouncementStatus(announcement, AnnouncementStore.AnnouncementStatus.Shown) + } + + override fun announcementsToShowFlow(): Flow> { + return combine( + announcementStore.announcementStateFlow(Announcement.Space), + announcementStore.announcementStateFlow(Announcement.NewNotificationSound), + ) { spaceAnnouncementStatus, newNotificationSoundStatus -> + buildList { + if (spaceAnnouncementStatus == AnnouncementStore.AnnouncementStatus.Show) { + add(Announcement.Space) + } + if (newNotificationSoundStatus == AnnouncementStore.AnnouncementStatus.Show) { + add(Announcement.NewNotificationSound) + } + } } } private suspend fun showSpaceAnnouncement() { - val currentValue = announcementStore.spaceAnnouncementFlow().first() - if (currentValue == AnnouncementStore.SpaceAnnouncement.NeverShown) { - announcementStore.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Show) + val currentValue = announcementStore.announcementStateFlow(Announcement.Space).first() + if (currentValue == AnnouncementStore.AnnouncementStatus.NeverShown) { + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStore.AnnouncementStatus.Show) } } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt index 05c42b784e..eb693d8a07 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt @@ -10,8 +10,9 @@ package io.element.android.features.announcement.impl.spaces import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.impl.store.AnnouncementStore -import io.element.android.features.announcement.impl.store.AnnouncementStore.SpaceAnnouncement +import io.element.android.features.announcement.impl.store.AnnouncementStore.AnnouncementStatus import io.element.android.libraries.architecture.Presenter import kotlinx.coroutines.launch @@ -26,7 +27,7 @@ class SpaceAnnouncementPresenter( fun handleEvents(event: SpaceAnnouncementEvents) { when (event) { SpaceAnnouncementEvents.Continue -> localCoroutineScope.launch { - announcementStore.setSpaceAnnouncementValue(SpaceAnnouncement.Shown) + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) } } } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt index dd12120d23..d69029d27a 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt @@ -7,15 +7,22 @@ package io.element.android.features.announcement.impl.store +import io.element.android.features.announcement.api.Announcement import kotlinx.coroutines.flow.Flow interface AnnouncementStore { - suspend fun setSpaceAnnouncementValue(value: SpaceAnnouncement) - fun spaceAnnouncementFlow(): Flow + suspend fun setAnnouncementStatus( + announcement: Announcement, + status: AnnouncementStatus, + ) + + fun announcementStateFlow( + announcement: Announcement, + ): Flow suspend fun reset() - enum class SpaceAnnouncement { + enum class AnnouncementStatus { NeverShown, Show, Shown, 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 922a4aaaa7..bb711bdbf6 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 @@ -12,11 +12,13 @@ import androidx.datastore.preferences.core.intPreferencesKey import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") +private val showNewNotificationSoundBannerKey = intPreferencesKey("showNewNotificationSoundBanner") @ContributesBinding(AppScope::class) @Inject @@ -25,16 +27,23 @@ class DefaultAnnouncementStore( ) : AnnouncementStore { private val store = preferenceDataStoreFactory.create("elementx_announcement") - override suspend fun setSpaceAnnouncementValue(value: AnnouncementStore.SpaceAnnouncement) { - store.edit { - it[spaceAnnouncementKey] = value.ordinal + override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStore.AnnouncementStatus) { + val key = announcement.toKey() + store.edit { prefs -> + prefs[key] = status.ordinal } } - override fun spaceAnnouncementFlow(): Flow { + override fun announcementStateFlow(announcement: Announcement): Flow { + val key = announcement.toKey() + // For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08) + val defaultStatus = when (announcement) { + Announcement.Space -> AnnouncementStore.AnnouncementStatus.NeverShown + Announcement.NewNotificationSound -> AnnouncementStore.AnnouncementStatus.Shown + } return store.data.map { prefs -> - val ordinal = prefs[spaceAnnouncementKey] ?: AnnouncementStore.SpaceAnnouncement.NeverShown.ordinal - AnnouncementStore.SpaceAnnouncement.entries.getOrElse(ordinal) { AnnouncementStore.SpaceAnnouncement.NeverShown } + val ordinal = prefs[key] ?: defaultStatus.ordinal + AnnouncementStore.AnnouncementStatus.entries.getOrElse(ordinal) { defaultStatus } } } @@ -42,3 +51,8 @@ class DefaultAnnouncementStore( store.edit { it.clear() } } } + +private fun Announcement.toKey() = when (this) { + Announcement.Space -> spaceAnnouncementKey + Announcement.NewNotificationSound -> showNewNotificationSoundBannerKey +} diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt index 87cd68a405..747c5e9ce0 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt @@ -8,6 +8,7 @@ package io.element.android.features.announcement.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.tests.testutils.test @@ -33,10 +34,10 @@ class AnnouncementPresenterTest { presenter.test { val state = awaitItem() assertThat(state.showSpaceAnnouncement).isFalse() - store.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Show) + store.setAnnouncementStatus(Announcement.Space, AnnouncementStore.AnnouncementStatus.Show) val updatedState = awaitItem() assertThat(updatedState.showSpaceAnnouncement).isTrue() - store.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Shown) + store.setAnnouncementStatus(Announcement.Space, AnnouncementStore.AnnouncementStatus.Shown) val finalState = awaitItem() assertThat(finalState.showSpaceAnnouncement).isFalse() } 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 74155ce13b..9a7abc9ffc 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 @@ -25,14 +25,14 @@ class DefaultAnnouncementServiceTest { val sut = createDefaultAnnouncementService( announcementStore = announcementStore, ) - assertThat(announcementStore.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.NeverShown) + assertThat(announcementStore.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.NeverShown) sut.showAnnouncement(Announcement.Space) - assertThat(announcementStore.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.Show) + assertThat(announcementStore.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Show) // Simulate user close the announcement - announcementStore.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Shown) + sut.onAnnouncementDismissed(Announcement.Space) // Entering again the space tab should not change the value sut.showAnnouncement(Announcement.Space) - assertThat(announcementStore.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.Shown) + assertThat(announcementStore.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Shown) } private fun createDefaultAnnouncementService( diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt index 5adb4dccec..0a85dd8a42 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt @@ -8,6 +8,7 @@ package io.element.android.features.announcement.impl.spaces import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.tests.testutils.test @@ -23,10 +24,10 @@ class SpaceAnnouncementPresenterTest { announcementStore = store, ) presenter.test { - assertThat(store.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.NeverShown) + assertThat(store.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.NeverShown) val state = awaitItem() state.eventSink(SpaceAnnouncementEvents.Continue) - assertThat(store.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.Shown) + assertThat(store.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Shown) } } } 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 0f99ef66bd..c119417bd5 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 @@ -7,23 +7,33 @@ package io.element.android.features.announcement.impl.store +import io.element.android.features.announcement.api.Announcement import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow class InMemoryAnnouncementStore( - initialSpaceAnnouncement: AnnouncementStore.SpaceAnnouncement = AnnouncementStore.SpaceAnnouncement.NeverShown, + initialSpaceAnnouncementStatus: AnnouncementStore.AnnouncementStatus = AnnouncementStore.AnnouncementStatus.NeverShown, + initialNewNotificationSoundAnnouncementStatus: AnnouncementStore.AnnouncementStatus = AnnouncementStore.AnnouncementStatus.NeverShown, ) : AnnouncementStore { - private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncement) - override suspend fun setSpaceAnnouncementValue(value: AnnouncementStore.SpaceAnnouncement) { - spaceAnnouncement.value = value + private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncementStatus) + private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) + + override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStore.AnnouncementStatus) { + when (announcement) { + Announcement.Space -> spaceAnnouncement.value = status + Announcement.NewNotificationSound -> newNotificationSoundAnnouncement.value = status + } } - override fun spaceAnnouncementFlow(): Flow { - return spaceAnnouncement.asStateFlow() + override fun announcementStateFlow(announcement: Announcement): Flow { + return when (announcement) { + Announcement.Space -> spaceAnnouncement.asStateFlow() + Announcement.NewNotificationSound -> newNotificationSoundAnnouncement.asStateFlow() + } } override suspend fun reset() { - spaceAnnouncement.value = AnnouncementStore.SpaceAnnouncement.NeverShown + spaceAnnouncement.value = AnnouncementStore.AnnouncementStatus.NeverShown } } diff --git a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt b/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt index a9d56e975c..74c2adf95f 100644 --- a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt +++ b/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt @@ -12,15 +12,34 @@ import androidx.compose.ui.Modifier import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow class FakeAnnouncementService( + initialAnnouncementsToShowFlowValue: List = emptyList(), val showAnnouncementResult: (Announcement) -> Unit = { lambdaError() }, + val onAnnouncementDismissedResult: (Announcement) -> Unit = { lambdaError() }, val renderResult: (Modifier) -> Unit = { lambdaError() }, ) : AnnouncementService { + private val announcementsToShowFlowValue = MutableStateFlow(initialAnnouncementsToShowFlowValue) + override suspend fun showAnnouncement(announcement: Announcement) { showAnnouncementResult(announcement) } + override suspend fun onAnnouncementDismissed(announcement: Announcement) { + onAnnouncementDismissedResult(announcement) + } + + override fun announcementsToShowFlow(): Flow> { + return announcementsToShowFlowValue.asStateFlow() + } + + fun emitAnnouncementsToShow(value: List) { + announcementsToShowFlowValue.value = value + } + @Composable override fun Render(modifier: Modifier) { renderResult(modifier) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 72a666a124..eb9d8b7ce3 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.search.RoomListSearchEvents @@ -82,6 +84,7 @@ class RoomListPresenter( private val notificationCleaner: NotificationCleaner, private val appPreferencesStore: AppPreferencesStore, private val seenInvitesStore: SeenInvitesStore, + private val announcementService: AnnouncementService, ) : Presenter { private val encryptionService = client.encryptionService @@ -98,7 +101,11 @@ class RoomListPresenter( } var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } - val showNewNotificationSoundBanner by appPreferencesStore.showNewNotificationSoundBanner().collectAsState(false) + val showNewNotificationSoundBanner by remember { + announcementService.announcementsToShowFlow().map { announcements -> + announcements.contains(Announcement.NewNotificationSound) + } + }.collectAsState(false) // Avatar indicator val hideInvitesAvatar by client.rememberHideInvitesAvatar() @@ -114,7 +121,7 @@ class RoomListPresenter( RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true RoomListEvents.DismissBanner -> securityBannerDismissed = true RoomListEvents.DismissNewNotificationSoundBanner -> coroutineScope.launch { - appPreferencesStore.setShowNewNotificationSoundBanner(false) + announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound) } RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility) is RoomListEvents.ShowContextMenu -> { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 041c43dab0..bef312cdea 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -12,6 +12,8 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.FakeDateTimeObserver import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory @@ -28,6 +30,7 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter @@ -606,23 +609,27 @@ class RoomListPresenterTest { ) roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomListService.postAllRooms(listOf(roomSummary)) - val store = InMemoryAppPreferencesStore() + val onAnnouncementDismissedResult = lambdaRecorder { } + val announcementService = FakeAnnouncementService( + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) val presenter = createRoomListPresenter( client = matrixClient, - appPreferencesStore = store, + announcementService = announcementService, ) presenter.test { - assertThat(store.showNewNotificationSoundBanner().first()).isFalse() + assertThat(announcementService.announcementsToShowFlow().first()).isEmpty() skipItems(1) val state = awaitItem() assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse() - store.setShowNewNotificationSoundBanner(true) - assertThat(store.showNewNotificationSoundBanner().first()).isTrue() + announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound)) assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isTrue() state.eventSink(RoomListEvents.DismissNewNotificationSoundBanner) + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.NewNotificationSound)) + // Simulate service updating the value + announcementService.emitAnnouncementsToShow(emptyList()) assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isFalse() - // Ensure store has been updated - assertThat(store.showNewNotificationSoundBanner().first()).isFalse() } } @@ -639,6 +646,7 @@ class RoomListPresenterTest { notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), + announcementService: AnnouncementService = FakeAnnouncementService(), ) = RoomListPresenter( client = client, leaveRoomPresenter = { leaveRoomState }, @@ -663,5 +671,6 @@ class RoomListPresenterTest { notificationCleaner = notificationCleaner, appPreferencesStore = appPreferencesStore, seenInvitesStore = seenInvitesStore, + announcementService = announcementService, ) } diff --git a/features/migration/impl/build.gradle.kts b/features/migration/impl/build.gradle.kts index dd445624b9..8926f258bd 100644 --- a/features/migration/impl/build.gradle.kts +++ b/features/migration/impl/build.gradle.kts @@ -19,6 +19,7 @@ android { setupDependencyInjection() dependencies { + implementation(projects.features.announcement.api) implementation(projects.features.migration.api) implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) @@ -34,5 +35,6 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.preferences.test) + testImplementation(projects.features.announcement.test) testImplementation(projects.features.rageshake.test) } diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08.kt index df1a508a6d..12355b4bc4 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08.kt @@ -10,7 +10,8 @@ package io.element.android.features.migration.impl.migrations import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet import dev.zacsweers.metro.Inject -import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService /** * Ensure the new notification sound banner is displayed, but only on application upgrade. @@ -18,13 +19,13 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore @ContributesIntoSet(AppScope::class) @Inject class AppMigration08( - private val appPreferencesStore: AppPreferencesStore, + private val announcementService: AnnouncementService, ) : AppMigration { override val order: Int = 8 override suspend fun migrate(isFreshInstall: Boolean) { if (!isFreshInstall) { - appPreferencesStore.setShowNewNotificationSoundBanner(true) + announcementService.showAnnouncement(Announcement.NewNotificationSound) } } } diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt index f908a10bcc..75ffa28882 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt @@ -8,27 +8,35 @@ package io.element.android.features.migration.impl.migrations import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Test class AppMigration08Test { @Test - fun `migration on fresh install should not modify the store`() = runTest { - val store = InMemoryAppPreferencesStore() - assertThat(store.showNewNotificationSoundBanner().first()).isFalse() - val migration = AppMigration08(store) + fun `migration on fresh install should not invoke the AnnouncementService`() = runTest { + val service = FakeAnnouncementService( + showAnnouncementResult = { lambdaError() }, + ) + val migration = AppMigration08(service) migration.migrate(isFreshInstall = true) - assertThat(store.showNewNotificationSoundBanner().first()).isFalse() + assertThat(service.announcementsToShowFlow().first()).isEmpty() } @Test - fun `migration on upgrade should modify the store`() = runTest { - val store = InMemoryAppPreferencesStore() - assertThat(store.showNewNotificationSoundBanner().first()).isFalse() - val migration = AppMigration08(store) + fun `migration on upgrade should invoke the AnnouncementService`() = runTest { + val showAnnouncementResult = lambdaRecorder { } + val service = FakeAnnouncementService( + showAnnouncementResult = showAnnouncementResult, + ) + val migration = AppMigration08(service) migration.migrate(isFreshInstall = false) - assertThat(store.showNewNotificationSoundBanner().first()).isTrue() + showAnnouncementResult.assertions().isCalledOnce() + .with(value(Announcement.NewNotificationSound)) } } diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index 18d7d4f092..7e47785088 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -37,8 +37,5 @@ interface AppPreferencesStore { suspend fun setTracingLogPacks(targets: Set) fun getTracingLogPacksFlow(): Flow> - suspend fun setShowNewNotificationSoundBanner(show: Boolean) - fun showNewNotificationSoundBanner(): Flow - suspend fun reset() } diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index 984503e673..b3890a8a7d 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -30,7 +30,6 @@ private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars") private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue") private val logLevelKey = stringPreferencesKey("logLevel") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") -private val showNewNotificationSoundBannerKey = booleanPreferencesKey("showNewNotificationSoundBanner") @ContributesBinding(AppScope::class) @Inject @@ -146,19 +145,6 @@ class DefaultAppPreferencesStore( } } - override suspend fun setShowNewNotificationSoundBanner(show: Boolean) { - store.edit { prefs -> - prefs[showNewNotificationSoundBannerKey] = show - } - } - - override fun showNewNotificationSoundBanner(): Flow { - return store.data.map { prefs -> - // Default is false, but a migration will set it to true on application upgrade (see AppMigration08) - prefs[showNewNotificationSoundBannerKey] ?: false - } - } - override suspend fun reset() { store.edit { it.clear() } } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index a601eb6b1d..d0ee298a66 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -22,7 +22,6 @@ class InMemoryAppPreferencesStore( theme: String? = null, logLevel: LogLevel = LogLevel.INFO, traceLockPacks: Set = emptySet(), - showNewNotificationSoundBanner: Boolean = false, ) : AppPreferencesStore { private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) @@ -31,7 +30,6 @@ class InMemoryAppPreferencesStore( private val tracingLogPacks = MutableStateFlow(traceLockPacks) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) - private val showNewNotificationSoundBanner = MutableStateFlow(showNewNotificationSoundBanner) override suspend fun setDeveloperModeEnabled(enabled: Boolean) { isDeveloperModeEnabled.value = enabled @@ -93,14 +91,6 @@ class InMemoryAppPreferencesStore( return tracingLogPacks } - override suspend fun setShowNewNotificationSoundBanner(show: Boolean) { - showNewNotificationSoundBanner.value = show - } - - override fun showNewNotificationSoundBanner(): Flow { - return showNewNotificationSoundBanner - } - override suspend fun reset() { // No op } From 908b359d51ba2599ae2c9195aa8f8b7b92b2ab51 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Oct 2025 10:22:30 +0200 Subject: [PATCH 2/6] Rename method. --- .../features/announcement/impl/AnnouncementPresenter.kt | 2 +- .../announcement/impl/DefaultAnnouncementService.kt | 6 +++--- .../features/announcement/impl/store/AnnouncementStore.kt | 2 +- .../announcement/impl/store/DefaultAnnouncementStore.kt | 2 +- .../announcement/impl/DefaultAnnouncementServiceTest.kt | 6 +++--- .../impl/spaces/SpaceAnnouncementPresenterTest.kt | 4 ++-- .../announcement/impl/store/InMemoryAnnouncementStore.kt | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt index 9600dd7229..d7e63e6ebe 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt @@ -24,7 +24,7 @@ class AnnouncementPresenter( @Composable override fun present(): AnnouncementState { val showSpaceAnnouncement by remember { - announcementStore.announcementStateFlow(Announcement.Space).map { + announcementStore.announcementStatusFlow(Announcement.Space).map { it == AnnouncementStore.AnnouncementStatus.Show } }.collectAsState(false) 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 a092025bdc..e373e46f5a 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 @@ -49,8 +49,8 @@ class DefaultAnnouncementService( override fun announcementsToShowFlow(): Flow> { return combine( - announcementStore.announcementStateFlow(Announcement.Space), - announcementStore.announcementStateFlow(Announcement.NewNotificationSound), + announcementStore.announcementStatusFlow(Announcement.Space), + announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), ) { spaceAnnouncementStatus, newNotificationSoundStatus -> buildList { if (spaceAnnouncementStatus == AnnouncementStore.AnnouncementStatus.Show) { @@ -64,7 +64,7 @@ class DefaultAnnouncementService( } private suspend fun showSpaceAnnouncement() { - val currentValue = announcementStore.announcementStateFlow(Announcement.Space).first() + val currentValue = announcementStore.announcementStatusFlow(Announcement.Space).first() if (currentValue == AnnouncementStore.AnnouncementStatus.NeverShown) { announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStore.AnnouncementStatus.Show) } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt index d69029d27a..6742adf468 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt @@ -16,7 +16,7 @@ interface AnnouncementStore { status: AnnouncementStatus, ) - fun announcementStateFlow( + fun announcementStatusFlow( announcement: Announcement, ): Flow 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 bb711bdbf6..452d18f6cf 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 @@ -34,7 +34,7 @@ class DefaultAnnouncementStore( } } - override fun announcementStateFlow(announcement: Announcement): Flow { + override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() // For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08) val defaultStatus = when (announcement) { 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 9a7abc9ffc..6188894a44 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 @@ -25,14 +25,14 @@ class DefaultAnnouncementServiceTest { val sut = createDefaultAnnouncementService( announcementStore = announcementStore, ) - assertThat(announcementStore.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.NeverShown) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.NeverShown) sut.showAnnouncement(Announcement.Space) - assertThat(announcementStore.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Show) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Show) // Simulate user close the announcement sut.onAnnouncementDismissed(Announcement.Space) // Entering again the space tab should not change the value sut.showAnnouncement(Announcement.Space) - assertThat(announcementStore.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Shown) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Shown) } private fun createDefaultAnnouncementService( diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt index 0a85dd8a42..c8dd199018 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt @@ -24,10 +24,10 @@ class SpaceAnnouncementPresenterTest { announcementStore = store, ) presenter.test { - assertThat(store.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.NeverShown) + assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.NeverShown) val state = awaitItem() state.eventSink(SpaceAnnouncementEvents.Continue) - assertThat(store.announcementStateFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Shown) + assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Shown) } } } 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 c119417bd5..6dfcac6f39 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 @@ -26,7 +26,7 @@ class InMemoryAnnouncementStore( } } - override fun announcementStateFlow(announcement: Announcement): Flow { + override fun announcementStatusFlow(announcement: Announcement): Flow { return when (announcement) { Announcement.Space -> spaceAnnouncement.asStateFlow() Announcement.NewNotificationSound -> newNotificationSoundAnnouncement.asStateFlow() From f6914c1acdc7d8eca8bc43f7b49fc096fe9d2979 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Oct 2025 10:26:15 +0200 Subject: [PATCH 3/6] Rename key and value --- .../announcement/impl/store/DefaultAnnouncementStore.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 452d18f6cf..7cdab9c82a 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 @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") -private val showNewNotificationSoundBannerKey = intPreferencesKey("showNewNotificationSoundBanner") +private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") @ContributesBinding(AppScope::class) @Inject @@ -54,5 +54,5 @@ class DefaultAnnouncementStore( private fun Announcement.toKey() = when (this) { Announcement.Space -> spaceAnnouncementKey - Announcement.NewNotificationSound -> showNewNotificationSoundBannerKey + Announcement.NewNotificationSound -> newNotificationSoundKey } From 196f3ef46228b27a62511e9ef335479b83dbaab6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Oct 2025 10:29:15 +0200 Subject: [PATCH 4/6] Improve InMemoryAnnouncementStore --- .../impl/store/InMemoryAnnouncementStore.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 6dfcac6f39..bac711c59c 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 @@ -20,20 +20,20 @@ class InMemoryAnnouncementStore( private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStore.AnnouncementStatus) { - when (announcement) { - Announcement.Space -> spaceAnnouncement.value = status - Announcement.NewNotificationSound -> newNotificationSoundAnnouncement.value = status - } + announcement.toMutableStateFlow().value = status } override fun announcementStatusFlow(announcement: Announcement): Flow { - return when (announcement) { - Announcement.Space -> spaceAnnouncement.asStateFlow() - Announcement.NewNotificationSound -> newNotificationSoundAnnouncement.asStateFlow() - } + return announcement.toMutableStateFlow().asStateFlow() } override suspend fun reset() { spaceAnnouncement.value = AnnouncementStore.AnnouncementStatus.NeverShown + newNotificationSoundAnnouncement.value = AnnouncementStore.AnnouncementStatus.NeverShown + } + + private fun Announcement.toMutableStateFlow() = when (this) { + Announcement.Space -> spaceAnnouncement + Announcement.NewNotificationSound -> newNotificationSoundAnnouncement } } From 3b78ab88c5e0463b49f20980a7f3e5429e9406d5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Oct 2025 11:30:49 +0200 Subject: [PATCH 5/6] Extract AnnouncementStatus to its own file. --- .../announcement/impl/AnnouncementPresenter.kt | 3 ++- .../impl/DefaultAnnouncementService.kt | 13 +++++++------ .../impl/spaces/SpaceAnnouncementPresenter.kt | 2 +- .../announcement/impl/store/AnnouncementStatus.kt | 14 ++++++++++++++ .../announcement/impl/store/AnnouncementStore.kt | 6 ------ .../impl/store/DefaultAnnouncementStore.kt | 10 +++++----- .../announcement/impl/AnnouncementPresenterTest.kt | 5 +++-- .../impl/DefaultAnnouncementServiceTest.kt | 7 ++++--- .../impl/spaces/SpaceAnnouncementPresenterTest.kt | 5 +++-- .../impl/store/InMemoryAnnouncementStore.kt | 12 ++++++------ 10 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStatus.kt diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt index d7e63e6ebe..432aad4a0a 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.libraries.architecture.Presenter import kotlinx.coroutines.flow.map @@ -25,7 +26,7 @@ class AnnouncementPresenter( override fun present(): AnnouncementState { val showSpaceAnnouncement by remember { announcementStore.announcementStatusFlow(Announcement.Space).map { - it == AnnouncementStore.AnnouncementStatus.Show + it == AnnouncementStatus.Show } }.collectAsState(false) return AnnouncementState( 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 e373e46f5a..6d616f14e9 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 @@ -21,6 +21,7 @@ import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementView +import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.libraries.architecture.Presenter import kotlinx.coroutines.flow.Flow @@ -38,13 +39,13 @@ class DefaultAnnouncementService( when (announcement) { Announcement.Space -> showSpaceAnnouncement() Announcement.NewNotificationSound -> { - announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStore.AnnouncementStatus.Show) + announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) } } } override suspend fun onAnnouncementDismissed(announcement: Announcement) { - announcementStore.setAnnouncementStatus(announcement, AnnouncementStore.AnnouncementStatus.Shown) + announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Shown) } override fun announcementsToShowFlow(): Flow> { @@ -53,10 +54,10 @@ class DefaultAnnouncementService( announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), ) { spaceAnnouncementStatus, newNotificationSoundStatus -> buildList { - if (spaceAnnouncementStatus == AnnouncementStore.AnnouncementStatus.Show) { + if (spaceAnnouncementStatus == AnnouncementStatus.Show) { add(Announcement.Space) } - if (newNotificationSoundStatus == AnnouncementStore.AnnouncementStatus.Show) { + if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) } } @@ -65,8 +66,8 @@ class DefaultAnnouncementService( private suspend fun showSpaceAnnouncement() { val currentValue = announcementStore.announcementStatusFlow(Announcement.Space).first() - if (currentValue == AnnouncementStore.AnnouncementStatus.NeverShown) { - announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStore.AnnouncementStatus.Show) + if (currentValue == AnnouncementStatus.NeverShown) { + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) } } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt index eb693d8a07..4d1b2f3e4b 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt @@ -11,8 +11,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore -import io.element.android.features.announcement.impl.store.AnnouncementStore.AnnouncementStatus import io.element.android.libraries.architecture.Presenter import kotlinx.coroutines.launch diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStatus.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStatus.kt new file mode 100644 index 0000000000..e275a140e1 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStatus.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.store + +enum class AnnouncementStatus { + NeverShown, + Show, + Shown, +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt index 6742adf468..3385165c52 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt @@ -21,10 +21,4 @@ interface AnnouncementStore { ): Flow suspend fun reset() - - enum class AnnouncementStatus { - NeverShown, - Show, - Shown, - } } 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 7cdab9c82a..37acf9f6b4 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 @@ -27,23 +27,23 @@ class DefaultAnnouncementStore( ) : AnnouncementStore { private val store = preferenceDataStoreFactory.create("elementx_announcement") - override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStore.AnnouncementStatus) { + override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { val key = announcement.toKey() store.edit { prefs -> prefs[key] = status.ordinal } } - override fun announcementStatusFlow(announcement: Announcement): Flow { + override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() // For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08) val defaultStatus = when (announcement) { - Announcement.Space -> AnnouncementStore.AnnouncementStatus.NeverShown - Announcement.NewNotificationSound -> AnnouncementStore.AnnouncementStatus.Shown + Announcement.Space -> AnnouncementStatus.NeverShown + Announcement.NewNotificationSound -> AnnouncementStatus.Shown } return store.data.map { prefs -> val ordinal = prefs[key] ?: defaultStatus.ordinal - AnnouncementStore.AnnouncementStatus.entries.getOrElse(ordinal) { defaultStatus } + AnnouncementStatus.entries.getOrElse(ordinal) { defaultStatus } } } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt index 747c5e9ce0..9290773b30 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.announcement.impl import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.tests.testutils.test @@ -34,10 +35,10 @@ class AnnouncementPresenterTest { presenter.test { val state = awaitItem() assertThat(state.showSpaceAnnouncement).isFalse() - store.setAnnouncementStatus(Announcement.Space, AnnouncementStore.AnnouncementStatus.Show) + store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) val updatedState = awaitItem() assertThat(updatedState.showSpaceAnnouncement).isTrue() - store.setAnnouncementStatus(Announcement.Space, AnnouncementStore.AnnouncementStatus.Shown) + store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) val finalState = awaitItem() assertThat(finalState.showSpaceAnnouncement).isFalse() } 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 6188894a44..f16d7db26d 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 @@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState import io.element.android.features.announcement.impl.spaces.aSpaceAnnouncementState +import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.libraries.architecture.Presenter @@ -25,14 +26,14 @@ class DefaultAnnouncementServiceTest { val sut = createDefaultAnnouncementService( announcementStore = announcementStore, ) - assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.NeverShown) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown) sut.showAnnouncement(Announcement.Space) - assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Show) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Show) // Simulate user close the announcement sut.onAnnouncementDismissed(Announcement.Space) // Entering again the space tab should not change the value sut.showAnnouncement(Announcement.Space) - assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Shown) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown) } private fun createDefaultAnnouncementService( diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt index c8dd199018..2c35bccf41 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.announcement.impl.spaces import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.store.AnnouncementStatus import io.element.android.features.announcement.impl.store.AnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.tests.testutils.test @@ -24,10 +25,10 @@ class SpaceAnnouncementPresenterTest { announcementStore = store, ) presenter.test { - assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.NeverShown) + assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown) val state = awaitItem() state.eventSink(SpaceAnnouncementEvents.Continue) - assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStore.AnnouncementStatus.Shown) + assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown) } } } 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 bac711c59c..f7d438784a 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 @@ -13,23 +13,23 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow class InMemoryAnnouncementStore( - initialSpaceAnnouncementStatus: AnnouncementStore.AnnouncementStatus = AnnouncementStore.AnnouncementStatus.NeverShown, - initialNewNotificationSoundAnnouncementStatus: AnnouncementStore.AnnouncementStatus = AnnouncementStore.AnnouncementStatus.NeverShown, + initialSpaceAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, ) : AnnouncementStore { private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) - override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStore.AnnouncementStatus) { + override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { announcement.toMutableStateFlow().value = status } - override fun announcementStatusFlow(announcement: Announcement): Flow { + override fun announcementStatusFlow(announcement: Announcement): Flow { return announcement.toMutableStateFlow().asStateFlow() } override suspend fun reset() { - spaceAnnouncement.value = AnnouncementStore.AnnouncementStatus.NeverShown - newNotificationSoundAnnouncement.value = AnnouncementStore.AnnouncementStatus.NeverShown + spaceAnnouncement.value = AnnouncementStatus.NeverShown + newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown } private fun Announcement.toMutableStateFlow() = when (this) { From 8133bb2a275bd88ff85eefe9de62b1fa2d6a379d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Oct 2025 11:38:11 +0200 Subject: [PATCH 6/6] Add missing tests on DefaultAnnouncementService --- .../impl/DefaultAnnouncementServiceTest.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 f16d7db26d..990f506223 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 @@ -7,6 +7,7 @@ package io.element.android.features.announcement.impl +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState @@ -36,6 +37,41 @@ class DefaultAnnouncementServiceTest { assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown) } + @Test + fun `when showing NewNotificationSound announcement, announcement is set to show even if it was already shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.NewNotificationSound) + assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.NewNotificationSound) + // Calling again showAnnouncement should set it back to Show + sut.showAnnouncement(Announcement.NewNotificationSound) + assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) + } + + @Test + fun `test announcementsToShowFlow`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + sut.announcementsToShowFlow().test { + assertThat(awaitItem()).isEmpty() + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) + assertThat(awaitItem()).containsExactly(Announcement.Space) + announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) + assertThat(awaitItem()).containsExactly(Announcement.Space, Announcement.NewNotificationSound) + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) + announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) + assertThat(awaitItem()).isEmpty() + } + } + private fun createDefaultAnnouncementService( announcementStore: AnnouncementStore = InMemoryAnnouncementStore(), announcementPresenter: Presenter = Presenter { anAnnouncementState() },