Merge pull request #5482 from element-hq/feature/bma/improveAnnouncementService

Improve AnnouncementService.
This commit is contained in:
Benoit Marty
2025-10-08 12:08:29 +02:00
committed by GitHub
21 changed files with 223 additions and 86 deletions

View File

@@ -9,4 +9,5 @@ package io.element.android.features.announcement.api
enum class Announcement {
Space,
NewNotificationSound,
}

View File

@@ -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<List<Announcement>>
/**
* Use this composable to render the announcement UI in Fullscreen.
*/
@Composable
fun Render(
modifier: Modifier,

View File

@@ -12,6 +12,8 @@ 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.AnnouncementStatus
import io.element.android.features.announcement.impl.store.AnnouncementStore
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.map
@@ -23,8 +25,8 @@ class AnnouncementPresenter(
@Composable
override fun present(): AnnouncementState {
val showSpaceAnnouncement by remember {
announcementStore.spaceAnnouncementFlow().map {
it == AnnouncementStore.SpaceAnnouncement.Show
announcementStore.announcementStatusFlow(Announcement.Space).map {
it == AnnouncementStatus.Show
}
}.collectAsState(false)
return AnnouncementState(

View File

@@ -21,8 +21,11 @@ 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
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@ContributesBinding(AppScope::class)
@@ -35,13 +38,36 @@ class DefaultAnnouncementService(
override suspend fun showAnnouncement(announcement: Announcement) {
when (announcement) {
Announcement.Space -> showSpaceAnnouncement()
Announcement.NewNotificationSound -> {
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
}
}
}
override suspend fun onAnnouncementDismissed(announcement: Announcement) {
announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Shown)
}
override fun announcementsToShowFlow(): Flow<List<Announcement>> {
return combine(
announcementStore.announcementStatusFlow(Announcement.Space),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
) { spaceAnnouncementStatus, newNotificationSoundStatus ->
buildList {
if (spaceAnnouncementStatus == AnnouncementStatus.Show) {
add(Announcement.Space)
}
if (newNotificationSoundStatus == 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.announcementStatusFlow(Announcement.Space).first()
if (currentValue == AnnouncementStatus.NeverShown) {
announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
}
}

View File

@@ -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.AnnouncementStatus
import io.element.android.features.announcement.impl.store.AnnouncementStore
import io.element.android.features.announcement.impl.store.AnnouncementStore.SpaceAnnouncement
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)
}
}
}

View File

@@ -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,
}

View File

@@ -7,17 +7,18 @@
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<SpaceAnnouncement>
suspend fun setAnnouncementStatus(
announcement: Announcement,
status: AnnouncementStatus,
)
fun announcementStatusFlow(
announcement: Announcement,
): Flow<AnnouncementStatus>
suspend fun reset()
enum class SpaceAnnouncement {
NeverShown,
Show,
Shown,
}
}

View File

@@ -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 newNotificationSoundKey = intPreferencesKey("newNotificationSound")
@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: AnnouncementStatus) {
val key = announcement.toKey()
store.edit { prefs ->
prefs[key] = status.ordinal
}
}
override fun spaceAnnouncementFlow(): Flow<AnnouncementStore.SpaceAnnouncement> {
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
val key = announcement.toKey()
// For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08)
val defaultStatus = when (announcement) {
Announcement.Space -> AnnouncementStatus.NeverShown
Announcement.NewNotificationSound -> 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
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 -> newNotificationSoundKey
}

View File

@@ -8,6 +8,8 @@
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
@@ -33,10 +35,10 @@ class AnnouncementPresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.showSpaceAnnouncement).isFalse()
store.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Show)
store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
val updatedState = awaitItem()
assertThat(updatedState.showSpaceAnnouncement).isTrue()
store.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Shown)
store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown)
val finalState = awaitItem()
assertThat(finalState.showSpaceAnnouncement).isFalse()
}

View File

@@ -7,10 +7,12 @@
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
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 +27,49 @@ class DefaultAnnouncementServiceTest {
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
assertThat(announcementStore.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.NeverShown)
assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.Space)
assertThat(announcementStore.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.Show)
assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(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.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(

View File

@@ -8,6 +8,8 @@
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
@@ -23,10 +25,10 @@ class SpaceAnnouncementPresenterTest {
announcementStore = store,
)
presenter.test {
assertThat(store.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.NeverShown)
assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown)
val state = awaitItem()
state.eventSink(SpaceAnnouncementEvents.Continue)
assertThat(store.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.Shown)
assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown)
}
}
}

View File

@@ -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: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = 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: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status
}
override fun spaceAnnouncementFlow(): Flow<AnnouncementStore.SpaceAnnouncement> {
return spaceAnnouncement.asStateFlow()
override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
return announcement.toMutableStateFlow().asStateFlow()
}
override suspend fun reset() {
spaceAnnouncement.value = AnnouncementStore.SpaceAnnouncement.NeverShown
spaceAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
}
private fun Announcement.toMutableStateFlow() = when (this) {
Announcement.Space -> spaceAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
}
}

View File

@@ -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<Announcement> = 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<List<Announcement>> {
return announcementsToShowFlowValue.asStateFlow()
}
fun emitAnnouncementsToShow(value: List<Announcement>) {
announcementsToShowFlowValue.value = value
}
@Composable
override fun Render(modifier: Modifier) {
renderResult(modifier)

View File

@@ -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<RoomListState> {
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 -> {

View File

@@ -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<Announcement, Unit> { }
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,
)
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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<Announcement, Unit> { }
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))
}
}

View File

@@ -37,8 +37,5 @@ interface AppPreferencesStore {
suspend fun setTracingLogPacks(targets: Set<TraceLogPack>)
fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>>
suspend fun setShowNewNotificationSoundBanner(show: Boolean)
fun showNewNotificationSoundBanner(): Flow<Boolean>
suspend fun reset()
}

View File

@@ -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<Boolean> {
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() }
}

View File

@@ -22,7 +22,6 @@ class InMemoryAppPreferencesStore(
theme: String? = null,
logLevel: LogLevel = LogLevel.INFO,
traceLockPacks: Set<TraceLogPack> = 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<Boolean> {
return showNewNotificationSoundBanner
}
override suspend fun reset() {
// No op
}