From a786f6a5e9eb71248e3fd10c00bb37b94673892b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Oct 2025 09:45:44 +0200 Subject: [PATCH] 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 }