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
commit 29367d094c
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 { enum class Announcement {
Space, Space,
NewNotificationSound,
} }

View file

@ -9,10 +9,18 @@ package io.element.android.features.announcement.api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
interface AnnouncementService { interface AnnouncementService {
suspend fun showAnnouncement(announcement: Announcement) 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 @Composable
fun Render( fun Render(
modifier: Modifier, modifier: Modifier,

View file

@ -12,6 +12,8 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject 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
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -23,8 +25,8 @@ class AnnouncementPresenter(
@Composable @Composable
override fun present(): AnnouncementState { override fun present(): AnnouncementState {
val showSpaceAnnouncement by remember { val showSpaceAnnouncement by remember {
announcementStore.spaceAnnouncementFlow().map { announcementStore.announcementStatusFlow(Announcement.Space).map {
it == AnnouncementStore.SpaceAnnouncement.Show it == AnnouncementStatus.Show
} }
}.collectAsState(false) }.collectAsState(false)
return AnnouncementState( 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.api.AnnouncementService
import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState 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.spaces.SpaceAnnouncementView
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
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -35,13 +38,36 @@ class DefaultAnnouncementService(
override suspend fun showAnnouncement(announcement: Announcement) { override suspend fun showAnnouncement(announcement: Announcement) {
when (announcement) { when (announcement) {
Announcement.Space -> showSpaceAnnouncement() 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() { private suspend fun showSpaceAnnouncement() {
val currentValue = announcementStore.spaceAnnouncementFlow().first() val currentValue = announcementStore.announcementStatusFlow(Announcement.Space).first()
if (currentValue == AnnouncementStore.SpaceAnnouncement.NeverShown) { if (currentValue == AnnouncementStatus.NeverShown) {
announcementStore.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Show) 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.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject 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
import io.element.android.features.announcement.impl.store.AnnouncementStore.SpaceAnnouncement
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -26,7 +27,7 @@ class SpaceAnnouncementPresenter(
fun handleEvents(event: SpaceAnnouncementEvents) { fun handleEvents(event: SpaceAnnouncementEvents) {
when (event) { when (event) {
SpaceAnnouncementEvents.Continue -> localCoroutineScope.launch { 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 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.Flow
interface AnnouncementStore { interface AnnouncementStore {
suspend fun setSpaceAnnouncementValue(value: SpaceAnnouncement) suspend fun setAnnouncementStatus(
fun spaceAnnouncementFlow(): Flow<SpaceAnnouncement> announcement: Announcement,
status: AnnouncementStatus,
)
fun announcementStatusFlow(
announcement: Announcement,
): Flow<AnnouncementStatus>
suspend fun reset() 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.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.announcement.api.Announcement
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@Inject @Inject
@ -25,16 +27,23 @@ class DefaultAnnouncementStore(
) : AnnouncementStore { ) : AnnouncementStore {
private val store = preferenceDataStoreFactory.create("elementx_announcement") private val store = preferenceDataStoreFactory.create("elementx_announcement")
override suspend fun setSpaceAnnouncementValue(value: AnnouncementStore.SpaceAnnouncement) { override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
store.edit { val key = announcement.toKey()
it[spaceAnnouncementKey] = value.ordinal 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 -> return store.data.map { prefs ->
val ordinal = prefs[spaceAnnouncementKey] ?: AnnouncementStore.SpaceAnnouncement.NeverShown.ordinal val ordinal = prefs[key] ?: defaultStatus.ordinal
AnnouncementStore.SpaceAnnouncement.entries.getOrElse(ordinal) { AnnouncementStore.SpaceAnnouncement.NeverShown } AnnouncementStatus.entries.getOrElse(ordinal) { defaultStatus }
} }
} }
@ -42,3 +51,8 @@ class DefaultAnnouncementStore(
store.edit { it.clear() } 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 package io.element.android.features.announcement.impl
import com.google.common.truth.Truth.assertThat 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.AnnouncementStore
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
@ -33,10 +35,10 @@ class AnnouncementPresenterTest {
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
assertThat(state.showSpaceAnnouncement).isFalse() assertThat(state.showSpaceAnnouncement).isFalse()
store.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Show) store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show)
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.showSpaceAnnouncement).isTrue() assertThat(updatedState.showSpaceAnnouncement).isTrue()
store.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Shown) store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.showSpaceAnnouncement).isFalse() assertThat(finalState.showSpaceAnnouncement).isFalse()
} }

View file

@ -7,10 +7,12 @@
package io.element.android.features.announcement.impl package io.element.android.features.announcement.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement 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.SpaceAnnouncementState
import io.element.android.features.announcement.impl.spaces.aSpaceAnnouncementState 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.AnnouncementStore
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -25,14 +27,49 @@ class DefaultAnnouncementServiceTest {
val sut = createDefaultAnnouncementService( val sut = createDefaultAnnouncementService(
announcementStore = announcementStore, announcementStore = announcementStore,
) )
assertThat(announcementStore.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.NeverShown) assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.Space) 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 // Simulate user close the announcement
announcementStore.setSpaceAnnouncementValue(AnnouncementStore.SpaceAnnouncement.Shown) sut.onAnnouncementDismissed(Announcement.Space)
// Entering again the space tab should not change the value // Entering again the space tab should not change the value
sut.showAnnouncement(Announcement.Space) 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( private fun createDefaultAnnouncementService(

View file

@ -8,6 +8,8 @@
package io.element.android.features.announcement.impl.spaces package io.element.android.features.announcement.impl.spaces
import com.google.common.truth.Truth.assertThat 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.AnnouncementStore
import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
@ -23,10 +25,10 @@ class SpaceAnnouncementPresenterTest {
announcementStore = store, announcementStore = store,
) )
presenter.test { presenter.test {
assertThat(store.spaceAnnouncementFlow().first()).isEqualTo(AnnouncementStore.SpaceAnnouncement.NeverShown) assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown)
val state = awaitItem() val state = awaitItem()
state.eventSink(SpaceAnnouncementEvents.Continue) 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 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
class InMemoryAnnouncementStore( class InMemoryAnnouncementStore(
initialSpaceAnnouncement: AnnouncementStore.SpaceAnnouncement = AnnouncementStore.SpaceAnnouncement.NeverShown, initialSpaceAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore { ) : AnnouncementStore {
private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncement) private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncementStatus)
override suspend fun setSpaceAnnouncementValue(value: AnnouncementStore.SpaceAnnouncement) { private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
spaceAnnouncement.value = value
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status
} }
override fun spaceAnnouncementFlow(): Flow<AnnouncementStore.SpaceAnnouncement> { override fun announcementStatusFlow(announcement: Announcement): Flow<AnnouncementStatus> {
return spaceAnnouncement.asStateFlow() return announcement.toMutableStateFlow().asStateFlow()
} }
override suspend fun reset() { 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.Announcement
import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.tests.testutils.lambda.lambdaError 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( class FakeAnnouncementService(
initialAnnouncementsToShowFlowValue: List<Announcement> = emptyList(),
val showAnnouncementResult: (Announcement) -> Unit = { lambdaError() }, val showAnnouncementResult: (Announcement) -> Unit = { lambdaError() },
val onAnnouncementDismissedResult: (Announcement) -> Unit = { lambdaError() },
val renderResult: (Modifier) -> Unit = { lambdaError() }, val renderResult: (Modifier) -> Unit = { lambdaError() },
) : AnnouncementService { ) : AnnouncementService {
private val announcementsToShowFlowValue = MutableStateFlow(initialAnnouncementsToShowFlowValue)
override suspend fun showAnnouncement(announcement: Announcement) { override suspend fun showAnnouncement(announcement: Announcement) {
showAnnouncementResult(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 @Composable
override fun Render(modifier: Modifier) { override fun Render(modifier: Modifier) {
renderResult(modifier) renderResult(modifier)

View file

@ -24,6 +24,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.Interaction 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.datasource.RoomListDataSource
import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.search.RoomListSearchEvents import io.element.android.features.home.impl.search.RoomListSearchEvents
@ -82,6 +84,7 @@ class RoomListPresenter(
private val notificationCleaner: NotificationCleaner, private val notificationCleaner: NotificationCleaner,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService private val encryptionService = client.encryptionService
@ -98,7 +101,11 @@ class RoomListPresenter(
} }
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } 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 // Avatar indicator
val hideInvitesAvatar by client.rememberHideInvitesAvatar() val hideInvitesAvatar by client.rememberHideInvitesAvatar()
@ -114,7 +121,7 @@ class RoomListPresenter(
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvents.DismissBanner -> securityBannerDismissed = true RoomListEvents.DismissBanner -> securityBannerDismissed = true
RoomListEvents.DismissNewNotificationSoundBanner -> coroutineScope.launch { RoomListEvents.DismissNewNotificationSoundBanner -> coroutineScope.launch {
appPreferencesStore.setShowNewNotificationSoundBanner(false) announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
} }
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility) RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
is RoomListEvents.ShowContextMenu -> { is RoomListEvents.ShowContextMenu -> {

View file

@ -12,6 +12,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction 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.FakeDateTimeObserver
import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory 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.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState 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.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
@ -606,23 +609,27 @@ class RoomListPresenterTest {
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(roomSummary)) roomListService.postAllRooms(listOf(roomSummary))
val store = InMemoryAppPreferencesStore() val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
appPreferencesStore = store, announcementService = announcementService,
) )
presenter.test { presenter.test {
assertThat(store.showNewNotificationSoundBanner().first()).isFalse() assertThat(announcementService.announcementsToShowFlow().first()).isEmpty()
skipItems(1) skipItems(1)
val state = awaitItem() val state = awaitItem()
assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse() assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse()
store.setShowNewNotificationSoundBanner(true) announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound))
assertThat(store.showNewNotificationSoundBanner().first()).isTrue()
assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isTrue() assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isTrue()
state.eventSink(RoomListEvents.DismissNewNotificationSoundBanner) state.eventSink(RoomListEvents.DismissNewNotificationSoundBanner)
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.NewNotificationSound))
// Simulate service updating the value
announcementService.emitAnnouncementsToShow(emptyList())
assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isFalse() assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isFalse()
// Ensure store has been updated
assertThat(store.showNewNotificationSoundBanner().first()).isFalse()
} }
} }
@ -639,6 +646,7 @@ class RoomListPresenterTest {
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
leaveRoomPresenter = { leaveRoomState }, leaveRoomPresenter = { leaveRoomState },
@ -663,5 +671,6 @@ class RoomListPresenterTest {
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
announcementService = announcementService,
) )
} }

View file

@ -19,6 +19,7 @@ android {
setupDependencyInjection() setupDependencyInjection()
dependencies { dependencies {
implementation(projects.features.announcement.api)
implementation(projects.features.migration.api) implementation(projects.features.migration.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
@ -34,5 +35,6 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.announcement.test)
testImplementation(projects.features.rageshake.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.AppScope
import dev.zacsweers.metro.ContributesIntoSet import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject 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. * 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) @ContributesIntoSet(AppScope::class)
@Inject @Inject
class AppMigration08( class AppMigration08(
private val appPreferencesStore: AppPreferencesStore, private val announcementService: AnnouncementService,
) : AppMigration { ) : AppMigration {
override val order: Int = 8 override val order: Int = 8
override suspend fun migrate(isFreshInstall: Boolean) { override suspend fun migrate(isFreshInstall: Boolean) {
if (!isFreshInstall) { if (!isFreshInstall) {
appPreferencesStore.setShowNewNotificationSoundBanner(true) announcementService.showAnnouncement(Announcement.NewNotificationSound)
} }
} }
} }

View file

@ -8,27 +8,35 @@
package io.element.android.features.migration.impl.migrations package io.element.android.features.migration.impl.migrations
import com.google.common.truth.Truth.assertThat 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.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class AppMigration08Test { class AppMigration08Test {
@Test @Test
fun `migration on fresh install should not modify the store`() = runTest { fun `migration on fresh install should not invoke the AnnouncementService`() = runTest {
val store = InMemoryAppPreferencesStore() val service = FakeAnnouncementService(
assertThat(store.showNewNotificationSoundBanner().first()).isFalse() showAnnouncementResult = { lambdaError() },
val migration = AppMigration08(store) )
val migration = AppMigration08(service)
migration.migrate(isFreshInstall = true) migration.migrate(isFreshInstall = true)
assertThat(store.showNewNotificationSoundBanner().first()).isFalse() assertThat(service.announcementsToShowFlow().first()).isEmpty()
} }
@Test @Test
fun `migration on upgrade should modify the store`() = runTest { fun `migration on upgrade should invoke the AnnouncementService`() = runTest {
val store = InMemoryAppPreferencesStore() val showAnnouncementResult = lambdaRecorder<Announcement, Unit> { }
assertThat(store.showNewNotificationSoundBanner().first()).isFalse() val service = FakeAnnouncementService(
val migration = AppMigration08(store) showAnnouncementResult = showAnnouncementResult,
)
val migration = AppMigration08(service)
migration.migrate(isFreshInstall = false) 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>) suspend fun setTracingLogPacks(targets: Set<TraceLogPack>)
fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>> fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>>
suspend fun setShowNewNotificationSoundBanner(show: Boolean)
fun showNewNotificationSoundBanner(): Flow<Boolean>
suspend fun reset() suspend fun reset()
} }

View file

@ -30,7 +30,6 @@ private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue") private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
private val logLevelKey = stringPreferencesKey("logLevel") private val logLevelKey = stringPreferencesKey("logLevel")
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
private val showNewNotificationSoundBannerKey = booleanPreferencesKey("showNewNotificationSoundBanner")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@Inject @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() { override suspend fun reset() {
store.edit { it.clear() } store.edit { it.clear() }
} }

View file

@ -22,7 +22,6 @@ class InMemoryAppPreferencesStore(
theme: String? = null, theme: String? = null,
logLevel: LogLevel = LogLevel.INFO, logLevel: LogLevel = LogLevel.INFO,
traceLockPacks: Set<TraceLogPack> = emptySet(), traceLockPacks: Set<TraceLogPack> = emptySet(),
showNewNotificationSoundBanner: Boolean = false,
) : AppPreferencesStore { ) : AppPreferencesStore {
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
@ -31,7 +30,6 @@ class InMemoryAppPreferencesStore(
private val tracingLogPacks = MutableStateFlow(traceLockPacks) private val tracingLogPacks = MutableStateFlow(traceLockPacks)
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
private val showNewNotificationSoundBanner = MutableStateFlow(showNewNotificationSoundBanner)
override suspend fun setDeveloperModeEnabled(enabled: Boolean) { override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
isDeveloperModeEnabled.value = enabled isDeveloperModeEnabled.value = enabled
@ -93,14 +91,6 @@ class InMemoryAppPreferencesStore(
return tracingLogPacks return tracingLogPacks
} }
override suspend fun setShowNewNotificationSoundBanner(show: Boolean) {
showNewNotificationSoundBanner.value = show
}
override fun showNewNotificationSoundBanner(): Flow<Boolean> {
return showNewNotificationSoundBanner
}
override suspend fun reset() { override suspend fun reset() {
// No op // No op
} }