change (media preview config) : final refactoring and tests

This commit is contained in:
ganfra
2025-06-30 21:31:58 +02:00
parent cbc10ad50e
commit 4734b560f7
27 changed files with 676 additions and 165 deletions

View File

@@ -26,6 +26,7 @@ class MediaPreviewConfigMigration @Inject constructor(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) {
@Suppress("DEPRECATION")
operator fun invoke() = sessionCoroutineScope.launch {
val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first()
val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first()
@@ -49,7 +50,8 @@ class MediaPreviewConfigMigration @Inject constructor(
appPreferencesStore.setTimelineMediaPreviewValue(null)
}
}
}.onFailure {
}
.onFailure {
Timber.d("Couldn't perform migration, failed to fetch media preview config.")
}
}

View File

@@ -0,0 +1,160 @@
/*
* 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.appnav.loggedin
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MediaPreviewConfigMigrationTest {
@Test
fun `when no local data exists, migration does nothing`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore()
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(null) }
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify no calls were made to set server config
// since there's nothing to migrate
}
@Test
fun `when local data exists and server has config, clears local data`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setHideInviteAvatars(true)
setTimelineMediaPreviewValue(MediaPreviewValue.Private)
}
val serverConfig = MediaPreviewConfig(
hideInviteAvatar = false,
mediaPreviewValue = MediaPreviewValue.On
)
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(serverConfig) }
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify local data was cleared
assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
}
@Test
fun `when local hideInviteAvatars exists and server has no config, migrates to server`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setHideInviteAvatars(true)
}
var setHideInviteAvatarsValue: Boolean? = null
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(null) },
setHideInviteAvatarsResult = { value ->
setHideInviteAvatarsValue = value
Result.success(Unit)
}
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify server was updated with local value
assertThat(setHideInviteAvatarsValue).isTrue()
// Verify local data was cleared
assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
}
@Test
fun `when local mediaPreviewValue exists and server has no config, migrates to server`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setTimelineMediaPreviewValue(MediaPreviewValue.Private)
}
var setMediaPreviewValue: MediaPreviewValue? = null
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(null) },
setMediaPreviewValueResult = { value ->
setMediaPreviewValue = value
Result.success(Unit)
}
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify server was updated with local value
assertThat(setMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
// Verify local data was cleared
assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
}
@Test
fun `when both local values exist and server has no config, migrates both to server`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setHideInviteAvatars(true)
setTimelineMediaPreviewValue(MediaPreviewValue.Off)
}
var setHideInviteAvatarsValue: Boolean? = null
var setMediaPreviewValue: MediaPreviewValue? = null
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(null) },
setHideInviteAvatarsResult = { value ->
setHideInviteAvatarsValue = value
Result.success(Unit)
},
setMediaPreviewValueResult = { value ->
setMediaPreviewValue = value
Result.success(Unit)
}
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify server was updated with both local values
assertThat(setHideInviteAvatarsValue).isTrue()
assertThat(setMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
// Verify local data was cleared
assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
}
@Test
fun `when fetch config fails, migration does nothing`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setHideInviteAvatars(true)
setTimelineMediaPreviewValue(MediaPreviewValue.Private)
}
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.failure(Exception("Network error")) }
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify local data was not cleared since migration failed
assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isTrue()
assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isEqualTo(MediaPreviewValue.Private)
}
private fun TestScope.createMigration(
appPreferencesStore: InMemoryAppPreferencesStore,
mediaPreviewService: FakeMediaPreviewService
) = MediaPreviewConfigMigration(
mediaPreviewService = mediaPreviewService,
appPreferencesStore = appPreferencesStore,
sessionCoroutineScope = this
)
}

View File

@@ -34,6 +34,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -49,7 +50,6 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.ui.model.toInviteSender
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Optional
@@ -67,7 +67,6 @@ class JoinRoomPresenter @AssistedInject constructor(
private val forgetRoom: ForgetRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta,
private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<JoinRoomState> {
interface Factory {
@@ -94,8 +93,11 @@ class JoinRoomPresenter @AssistedInject constructor(
var knockMessage by rememberSaveable { mutableStateOf("") }
var isDismissingContent by remember { mutableStateOf(false) }
val hideInviteAvatars by remember {
appPreferencesStore.getHideInviteAvatarsFlow()
}.collectAsState(initial = false)
matrixClient
.mediaPreviewService()
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() }
val contentState by produceState<ContentState>(

View File

@@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import java.util.Optional
@Module
@@ -37,7 +36,6 @@ object JoinRoomModule {
forgetRoom: ForgetRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta,
appPreferencesStore: AppPreferencesStore,
seenInvitesStore: SeenInvitesStore,
): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory {
@@ -61,7 +59,6 @@ object JoinRoomModule {
cancelKnockRoom = cancelKnockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta,
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore,
)
}

View File

@@ -52,8 +52,6 @@ import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.matrix.ui.model.toInviteSender
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
@@ -1057,7 +1055,6 @@ class JoinRoomPresenterTest {
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
@@ -1073,7 +1070,6 @@ class JoinRoomPresenterTest {
forgetRoom = forgetRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore,
)
}

View File

@@ -14,16 +14,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.toImmutableSet
import javax.inject.Inject
class TimelineProtectionPresenter @Inject constructor(
private val appPreferencesStore: AppPreferencesStore,
private val mediaPreviewService: MediaPreviewService,
private val room: BaseRoom,
) : Presenter<TimelineProtectionState> {
private val allowedEvents = mutableStateOf<Set<EventId>>(setOf())
@@ -31,8 +31,8 @@ class TimelineProtectionPresenter @Inject constructor(
@Composable
override fun present(): TimelineProtectionState {
val mediaPreviewValue = remember {
appPreferencesStore.getTimelineMediaPreviewValueFlow()
}.collectAsState(initial = MediaPreviewValue.On)
mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue }
}.collectAsState()
val roomInfo = room.roomInfoFlow.collectAsState()
val protectionState by remember {
derivedStateOf {

View File

@@ -8,17 +8,19 @@
package io.element.android.features.messages.impl.timeline.protection
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -38,10 +40,10 @@ class TimelineProtectionPresenterTest {
@Test
fun `present - media preview value off`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Off)
val presenter = createPresenter(appPreferencesStore)
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false)
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
val presenter = createPresenter(mediaPreviewService = mediaPreviewService)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
// ShowContent with null should have no effect.
@@ -54,11 +56,11 @@ class TimelineProtectionPresenterTest {
@Test
fun `present - media preview value private in public room`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private)
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false)
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Public))
val presenter = createPresenter(appPreferencesStore, room)
val presenter = createPresenter(mediaPreviewService = mediaPreviewService, room = room)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
// ShowContent with null should have no effect.
@@ -71,9 +73,10 @@ class TimelineProtectionPresenterTest {
@Test
fun `present - media preview value private in non public room`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private)
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false)
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite))
val presenter = createPresenter(appPreferencesStore, room)
val presenter = createPresenter(mediaPreviewService = mediaPreviewService, room = room)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll)
@@ -84,10 +87,10 @@ class TimelineProtectionPresenterTest {
}
private fun createPresenter(
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
room: BaseRoom = FakeBaseRoom(),
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
) = TimelineProtectionPresenter(
appPreferencesStore = appPreferencesStore,
mediaPreviewService = mediaPreviewService,
room = room,
)
}

View File

@@ -8,21 +8,15 @@
package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CoroutineScope
@@ -51,6 +45,8 @@ class AdvancedSettingsPresenter @Inject constructor(
appPreferencesStore.getThemeFlow().mapToTheme()
}.collectAsState(initial = Theme.System)
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
val themeOption by remember {
derivedStateOf {
when (theme.value) {
@@ -89,10 +85,7 @@ class AdvancedSettingsPresenter @Inject constructor(
isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia,
theme = themeOption,
hideInviteAvatars = mediaPreviewConfigStateStore.hideInviteAvatars.value,
timelineMediaPreviewValue = mediaPreviewConfigStateStore.timelineMediaPreviewValue.value,
setHideInviteAvatarsAction = mediaPreviewConfigStateStore.setHideInviteAvatarsAction.value,
setTimelineMediaPreviewAction = mediaPreviewConfigStateStore.setTimelineMediaPreviewAction.value,
mediaPreviewConfigState = mediaPreviewConfigState,
eventSink = ::handleEvents,
)
}

View File

@@ -10,9 +10,7 @@ package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings
data class AdvancedSettingsState(
@@ -20,10 +18,7 @@ data class AdvancedSettingsState(
val isSharePresenceEnabled: Boolean,
val doesCompressMedia: Boolean,
val theme: ThemeOption,
val hideInviteAvatars: Boolean,
val timelineMediaPreviewValue: MediaPreviewValue,
val setHideInviteAvatarsAction: AsyncAction<Unit>,
val setTimelineMediaPreviewAction: AsyncAction<Unit>,
val mediaPreviewConfigState: MediaPreviewConfigState,
val eventSink: (AdvancedSettingsEvents) -> Unit
)

View File

@@ -29,8 +29,8 @@ fun aAdvancedSettingsState(
isDeveloperModeEnabled: Boolean = false,
isSharePresenceEnabled: Boolean = false,
doesCompressMedia: Boolean = false,
hideInviteAvatars: Boolean = false,
theme: ThemeOption = ThemeOption.System,
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
@@ -40,9 +40,11 @@ fun aAdvancedSettingsState(
isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia,
theme = theme,
hideInviteAvatars = hideInviteAvatars,
timelineMediaPreviewValue = timelineMediaPreviewValue,
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
setHideInviteAvatarsAction = setHideInviteAvatarsAction,
mediaPreviewConfigState = MediaPreviewConfigState(
hideInviteAvatars = hideInviteAvatars,
timelineMediaPreviewValue = timelineMediaPreviewValue,
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
setHideInviteAvatarsAction = setHideInviteAvatarsAction
),
eventSink = eventSink
)

View File

@@ -133,11 +133,11 @@ private fun ModerationAndSafety(
) {
PreferenceSwitch(
title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title),
isChecked = state.hideInviteAvatars,
isChecked = state.mediaPreviewConfigState.hideInviteAvatars,
onCheckedChange = {
state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it))
},
enabled = !state.setHideInviteAvatarsAction.isLoading()
enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading()
)
ListSectionHeader(
title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title),
@@ -153,27 +153,36 @@ private fun ModerationAndSafety(
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Off, compact = true),
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off,
compact = true
),
onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
},
enabled = !state.setTimelineMediaPreviewAction.isLoading()
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Private, compact = true),
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private,
compact = true
),
onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
},
enabled = !state.setTimelineMediaPreviewAction.isLoading()
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.On, compact = true),
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On,
compact = true
),
onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
},
enabled = !state.setTimelineMediaPreviewAction.isLoading()
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
}
}

View File

@@ -7,7 +7,7 @@
package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.State
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.AsyncAction
@@ -21,27 +21,29 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
interface MediaPreviewConfigStateStore {
val hideInviteAvatars: State<Boolean>
val timelineMediaPreviewValue: State<MediaPreviewValue>
val setHideInviteAvatarsAction: State<AsyncAction<Unit>>
val setTimelineMediaPreviewAction: State<AsyncAction<Unit>>
data class MediaPreviewConfigState(
val hideInviteAvatars: Boolean,
val timelineMediaPreviewValue: MediaPreviewValue,
val setHideInviteAvatarsAction: AsyncAction<Unit>,
val setTimelineMediaPreviewAction: AsyncAction<Unit>,
)
interface MediaPreviewConfigStateStore {
@Composable
fun state(): MediaPreviewConfigState
fun setHideInviteAvatars(hide: Boolean)
fun setTimelineMediaPreviewValue(value: MediaPreviewValue)
}
@ContributesBinding(SessionScope::class, boundType = MediaPreviewConfigStateStore::class)
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultMediaPreviewConfigStateStore @Inject constructor(
@SessionCoroutineScope
@@ -49,19 +51,19 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor(
private val mediaPreviewService: MediaPreviewService,
private val snackbarDispatcher: SnackbarDispatcher,
) : MediaPreviewConfigStateStore {
override val hideInviteAvatars = mutableStateOf(false)
override val timelineMediaPreviewValue = mutableStateOf(MediaPreviewValue.On)
override val setHideInviteAvatarsAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
override val setTimelineMediaPreviewAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
private val hideInviteAvatars = mutableStateOf(false)
private val timelineMediaPreviewValue = mutableStateOf(MediaPreviewValue.On)
private val setHideInviteAvatarsAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
private val setTimelineMediaPreviewAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
init {
val configFlow = mediaPreviewService.getMediaPreviewConfigFlow().shareIn(sessionCoroutineScope, SharingStarted.Eagerly)
val hideInviteAvatarsFlow = configFlow.mapNotNull { it?.hideInviteAvatar }.distinctUntilChanged()
val timelineMediaPreviewFlow = configFlow.mapNotNull { it?.mediaPreviewValue }.distinctUntilChanged()
val configFlow = mediaPreviewService.mediaPreviewConfigFlow
val hideInviteAvatarsFlow = configFlow.map { it.hideInviteAvatar }.distinctUntilChanged()
val timelineMediaPreviewFlow = configFlow.map { it.mediaPreviewValue }.distinctUntilChanged()
hideInviteAvatarsFlow
.onEach {
Timber.d("Hide invi@te avatars changed to $it")
Timber.d("Hide invite avatars changed to $it")
hideInviteAvatars.value = it
}
.launchIn(sessionCoroutineScope)
@@ -74,6 +76,16 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor(
.launchIn(sessionCoroutineScope)
}
@Composable
override fun state(): MediaPreviewConfigState {
return MediaPreviewConfigState(
hideInviteAvatars = hideInviteAvatars.value,
timelineMediaPreviewValue = timelineMediaPreviewValue.value,
setHideInviteAvatarsAction = setHideInviteAvatarsAction.value,
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction.value,
)
}
override fun setHideInviteAvatars(hide: Boolean) {
sessionCoroutineScope.launch {
Timber.d("Setting hide invite avatars to $hide")
@@ -106,4 +118,3 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor(
}
}
}

View File

@@ -11,14 +11,12 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -38,6 +36,10 @@ class AdvancedSettingsPresenterTest {
assertThat(isSharePresenceEnabled).isTrue()
assertThat(doesCompressMedia).isTrue()
assertThat(theme).isEqualTo(ThemeOption.System)
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
@@ -128,56 +130,92 @@ class AdvancedSettingsPresenterTest {
@Test
fun `present - hide invite avatars`() = runTest {
val presenter = createAdvancedSettingsPresenter()
val mediaPreviewStore = FakeMediaPreviewConfigStateStore()
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(hideInviteAvatars).isFalse()
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true))
}
with(awaitItem()) {
assertThat(hideInviteAvatars).isTrue()
assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(false))
}
with(awaitItem()) {
assertThat(hideInviteAvatars).isFalse()
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
}
}
assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false))
}
@Test
fun `present - timeline media preview value`() = runTest {
val mediaPreviewStore = FakeMediaPreviewConfigStateStore()
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
}
with(awaitItem()) {
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
}
with(awaitItem()) {
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
}
}
assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo(
listOf(MediaPreviewValue.Off, MediaPreviewValue.Private)
)
}
@Test
fun `present - media preview state with custom initial values`() = runTest {
val mediaPreviewStore = FakeMediaPreviewConfigStateStore(
hideInviteAvatarsValue = true,
timelineMediaPreviewValue = MediaPreviewValue.Private
)
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
}
}
}
@Test
fun `present - timeline media preview value`() = runTest {
val mediaPreviewConfigFlow = MutableStateFlow<MediaPreviewConfig?>(null)
val presenter = createAdvancedSettingsPresenter(
matrixClient = FakeMatrixClient(
mediaPreviewConfigFlow = mediaPreviewConfigFlow
)
fun `present - async actions state`() = runTest {
val mediaPreviewStore = FakeMediaPreviewConfigStateStore(
setHideInviteAvatarsActionValue = AsyncAction.Loading,
setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit)
)
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
}
with(awaitItem()) {
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
}
with(awaitItem()) {
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading)
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit))
}
}
}
private fun createAdvancedSettingsPresenter(
private fun CoroutineScope.createAdvancedSettingsPresenter(
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
matrixClient: MatrixClient = FakeMatrixClient(),
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
) = AdvancedSettingsPresenter(
appPreferencesStore = appPreferencesStore,
sessionPreferencesStore = sessionPreferencesStore,
matrixClient = matrixClient,
mediaPreviewConfigStateStore = mediaPreviewConfigStateStore,
sessionCoroutineScope = this,
)
}

View File

@@ -15,6 +15,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
@@ -131,7 +132,7 @@ class AdvancedSettingsViewTest {
}
@Test
@Config(qualifiers = "h640dp")
@Config(qualifiers = "h1080dp")
fun `clicking on hide invite avatars emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView(
@@ -145,8 +146,8 @@ class AdvancedSettingsViewTest {
}
@Test
@Config(qualifiers = "h640dp")
fun `clicking on timeline media preview emits the expected event`() {
@Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview always hide emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
@@ -157,6 +158,65 @@ class AdvancedSettingsViewTest {
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
}
@Test
@Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview private rooms emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.On
),
)
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
}
@Test
@Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview always show emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.Off
),
)
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
}
@Test
@Config(qualifiers = "h1080dp")
fun `hide invite avatars toggle is disabled when action is loading`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false)
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
eventSink = eventsRecorder,
hideInviteAvatars = false,
setHideInviteAvatarsAction = AsyncAction.Loading
),
)
// The toggle should be disabled, so clicking should not emit any events
rule.clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title)
}
@Test
@Config(qualifiers = "h1080dp")
fun `timeline media preview options are disabled when action is loading`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false)
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.On,
setTimelineMediaPreviewAction = AsyncAction.Loading
),
)
// The options should be disabled, so clicking should not emit any events
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAdvancedSettingsView(

View File

@@ -0,0 +1,51 @@
/*
* 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.preferences.impl.advanced
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
class FakeMediaPreviewConfigStateStore(
hideInviteAvatarsValue: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
setHideInviteAvatarsActionValue: AsyncAction<Unit> = AsyncAction.Uninitialized,
setTimelineMediaPreviewActionValue: AsyncAction<Unit> = AsyncAction.Uninitialized,
) : MediaPreviewConfigStateStore {
private val hideInviteAvatars = mutableStateOf(hideInviteAvatarsValue)
private val timelineMediaPreviewValue = mutableStateOf(timelineMediaPreviewValue)
private val setHideInviteAvatarsAction = mutableStateOf(setHideInviteAvatarsActionValue)
private val setTimelineMediaPreviewAction = mutableStateOf(setTimelineMediaPreviewActionValue)
private val setHideInviteAvatarsEvents = mutableListOf<Boolean>()
private val setTimelineMediaPreviewValueEvents = mutableListOf<MediaPreviewValue>()
@Composable
override fun state(): MediaPreviewConfigState {
return MediaPreviewConfigState(
hideInviteAvatars = hideInviteAvatars.value,
timelineMediaPreviewValue = timelineMediaPreviewValue.value,
setHideInviteAvatarsAction = setHideInviteAvatarsAction.value,
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction.value,
)
}
override fun setHideInviteAvatars(hide: Boolean) {
setHideInviteAvatarsEvents.add(hide)
hideInviteAvatars.value = hide
}
override fun setTimelineMediaPreviewValue(value: MediaPreviewValue) {
setTimelineMediaPreviewValueEvents.add(value)
timelineMediaPreviewValue.value = value
}
fun getSetHideInviteAvatarsEvents(): List<Boolean> = setHideInviteAvatarsEvents.toList()
fun getSetTimelineMediaPreviewValueEvents(): List<MediaPreviewValue> = setTimelineMediaPreviewValueEvents.toList()
}

View File

@@ -0,0 +1,206 @@
/*
* 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.preferences.impl.advanced
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class MediaPreviewConfigStateStoreTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `initial state is correct with default values`() = runTest {
val store = createMediaPreviewConfigStateStore()
moleculeFlow(RecompositionMode.Immediate) {
store.state()
}.test {
val initialState = awaitItem()
assertThat(initialState.hideInviteAvatars).isFalse()
assertThat(initialState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
assertThat(initialState.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
@Test
fun `state updates when config flow emits new values`() = runTest {
val configFlow = MutableStateFlow(MediaPreviewConfig.DEFAULT)
val mediaPreviewService = FakeMediaPreviewService(configFlow)
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
moleculeFlow(RecompositionMode.Immediate) {
store.state()
}.test {
// Initial state
val initialState = awaitItem()
assertThat(initialState.hideInviteAvatars).isFalse()
assertThat(initialState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
// Update config
configFlow.value = MediaPreviewConfig(hideInviteAvatar = true, mediaPreviewValue = MediaPreviewValue.Private)
skipItems(1)
// Updated state
val updatedState = awaitItem()
assertThat(updatedState.hideInviteAvatars).isTrue()
assertThat(updatedState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
}
}
@Test
fun `setHideInviteAvatars updates state and calls service on success`() = runTest {
val setHideInviteAvatarsValueLambda = lambdaRecorder<Boolean, Result<Unit>> { Result.success(Unit) }
val mediaPreviewService = FakeMediaPreviewService(
setHideInviteAvatarsResult = setHideInviteAvatarsValueLambda
)
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
moleculeFlow(RecompositionMode.Immediate) {
store.state()
}.test {
awaitItem().also { state ->
assertThat(state.hideInviteAvatars).isFalse()
}
store.setHideInviteAvatars(true)
awaitItem().also { state ->
assertThat(state.hideInviteAvatars).isTrue()
}
awaitItem().also { state ->
assertThat(state.hideInviteAvatars).isTrue()
assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.hideInviteAvatars).isTrue()
assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Success::class.java)
}
assert(setHideInviteAvatarsValueLambda).isCalledOnce()
}
}
@Test
fun `setHideInviteAvatars reverts state on failure`() = runTest {
val setHideInviteAvatarsValueLambda = lambdaRecorder<Boolean, Result<Unit>> {
Result.failure(Exception())
}
val mediaPreviewService = FakeMediaPreviewService(
setHideInviteAvatarsResult = setHideInviteAvatarsValueLambda
)
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
moleculeFlow(RecompositionMode.Immediate) {
store.state()
}.test {
awaitItem().also { state ->
assertThat(state.hideInviteAvatars).isFalse()
}
store.setHideInviteAvatars(true)
awaitItem().also { state ->
assertThat(state.hideInviteAvatars).isTrue()
}
awaitItem().also { state ->
assertThat(state.hideInviteAvatars).isTrue()
assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Loading::class.java)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.hideInviteAvatars).isFalse()
assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Failure::class.java)
}
assert(setHideInviteAvatarsValueLambda).isCalledOnce()
}
}
@Test
fun `setTimelineMediaPreviewValue updates state and calls service on success`() = runTest {
val setMediaPreviewValueLambda = lambdaRecorder<MediaPreviewValue, Result<Unit>> { Result.success(Unit) }
val mediaPreviewService = FakeMediaPreviewService(
setMediaPreviewValueResult = setMediaPreviewValueLambda
)
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
moleculeFlow(RecompositionMode.Immediate) {
store.state()
}.test {
awaitItem().also { state ->
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
}
store.setTimelineMediaPreviewValue(MediaPreviewValue.Off)
awaitItem().also { state ->
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
}
awaitItem().also { state ->
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Success::class.java)
}
assert(setMediaPreviewValueLambda).isCalledOnce()
}
}
@Test
fun `setTimelineMediaPreviewValue reverts state on failure`() = runTest {
val setMediaPreviewValueLambda = lambdaRecorder<MediaPreviewValue, Result<Unit>> {
Result.failure(Exception())
}
val mediaPreviewService = FakeMediaPreviewService(
setMediaPreviewValueResult = setMediaPreviewValueLambda
)
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
moleculeFlow(RecompositionMode.Immediate) {
store.state()
}.test {
awaitItem().also { state ->
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
}
store.setTimelineMediaPreviewValue(MediaPreviewValue.Off)
awaitItem().also { state ->
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
}
awaitItem().also { state ->
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Loading::class.java)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Failure::class.java)
}
assert(setMediaPreviewValueLambda).isCalledOnce()
}
}
private fun TestScope.createMediaPreviewConfigStateStore(
mediaPreviewService: FakeMediaPreviewService = FakeMediaPreviewService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher()
): MediaPreviewConfigStateStore = DefaultMediaPreviewConfigStateStore(
sessionCoroutineScope = backgroundScope,
mediaPreviewService = mediaPreviewService,
snackbarDispatcher = snackbarDispatcher
)
}

View File

@@ -37,6 +37,7 @@ import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -120,8 +121,11 @@ class RoomListPresenter @Inject constructor(
// Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
val hideInvitesAvatar by remember {
appPreferencesStore.getHideInviteAvatarsFlow()
}.collectAsState(initial = false)
client
.mediaPreviewService()
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }

View File

@@ -8,7 +8,6 @@
package io.element.android.libraries.matrix.api
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.ProgressCallback
@@ -20,9 +19,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
@@ -44,7 +41,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import java.util.Optional
interface MatrixClient {
@@ -175,7 +171,6 @@ interface MatrixClient {
* Return true if Livekit Rtc is supported, i.e. if Element Call is available.
*/
suspend fun isLivekitRtcSupported(): Boolean
}
/**

View File

@@ -13,4 +13,14 @@ package io.element.android.libraries.matrix.api.media
data class MediaPreviewConfig(
val mediaPreviewValue: MediaPreviewValue,
val hideInviteAvatar: Boolean,
)
) {
companion object {
/**
* The default config if unknown (no local nor server config).
*/
val DEFAULT = MediaPreviewConfig(
mediaPreviewValue = MediaPreviewValue.On,
hideInviteAvatar = false
)
}
}

View File

@@ -7,7 +7,7 @@
package io.element.android.libraries.matrix.api.media
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface MediaPreviewService {
/**
@@ -19,24 +19,17 @@ interface MediaPreviewService {
* Will emit the media preview config known by the client.
* This will emit a new value when received from sync.
*/
fun getMediaPreviewConfigFlow(): Flow<MediaPreviewConfig?>
/**
* Get the media preview display policy from the cache. This value is updated through sync.
*/
suspend fun getMediaPreviewValue(): MediaPreviewValue?
/**
* Get the invite avatars display policy from the cache. This value is updated through sync.
*/
suspend fun getHideInviteAvatars(): Boolean
val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig>
/**
* Set the media preview display policy. This will update the value on the server and update the local value when successful.
*/
suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit>
/**
* Set the invite avatars display policy. This will update the value on the server and update the local value when successful.
*/
suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit>
}
fun MediaPreviewService.getMediaPreviewValue() = mediaPreviewConfigFlow.value.mediaPreviewValue

View File

@@ -26,9 +26,7 @@ import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
@@ -111,7 +109,6 @@ import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientException
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.InviteAvatars
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.RoomInfoListener
@@ -220,6 +217,7 @@ class RustMatrixClient(
)
private val mediaPreviewService = RustMediaPreviewService(
sessionCoroutineScope = sessionCoroutineScope,
innerClient = innerClient,
sessionDispatcher = sessionDispatcher,
)
@@ -694,8 +692,6 @@ class RustMatrixClient(
innerClient.isLivekitRtcSupported()
}
private suspend fun File.getCacheSize(
includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) {

View File

@@ -13,7 +13,10 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.InviteAvatars
@@ -22,27 +25,27 @@ import org.matrix.rustcomponents.sdk.MediaPreviews
import org.matrix.rustcomponents.sdk.MediaPreviewConfig as RustMediaPreviewConfig
class RustMediaPreviewService(
sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
private val innerClient: Client,
) : MediaPreviewService {
override val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig> =
innerClient
.getMediaPreviewConfigFlow()
.stateIn(sessionCoroutineScope, started = SharingStarted.Lazily, initialValue = MediaPreviewConfig.DEFAULT)
override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.fetchMediaPreviewConfig()?.into()
}
}
override fun getMediaPreviewConfigFlow(): Flow<MediaPreviewConfig?> = innerClient.getMediaPreviewConfigFlow()
override suspend fun getMediaPreviewValue(): MediaPreviewValue? = innerClient.getMediaPreviewDisplayPolicy()?.into()
override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into())
}
}
override suspend fun getHideInviteAvatars(): Boolean = innerClient.getInviteAvatarsDisplayPolicy() == InviteAvatars.OFF
override suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
val inviteAvatars = if (hide) InviteAvatars.OFF else InviteAvatars.ON
@@ -61,7 +64,9 @@ private fun RustMediaPreviewConfig.into(): MediaPreviewConfig {
private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow {
subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener {
override fun onChange(mediaPreviewConfig: RustMediaPreviewConfig?) {
trySend(mediaPreviewConfig?.into())
if (mediaPreviewConfig != null) {
trySend(mediaPreviewConfig.into())
}
}
})
}
@@ -81,4 +86,3 @@ private fun MediaPreviews.into(): MediaPreviewValue {
MediaPreviews.PRIVATE -> MediaPreviewValue.Private
}
}

View File

@@ -17,8 +17,6 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService
@@ -96,7 +94,6 @@ class FakeMatrixClient(
private val canReportRoomLambda: () -> Boolean = { false },
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -246,7 +243,6 @@ class FakeMatrixClient(
return RoomMembershipObserver()
}
// Mocks
fun givenCreateRoomResult(result: Result<RoomId>) {

View File

@@ -12,34 +12,19 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeMediaPreviewService(
override val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig> = MutableStateFlow(MediaPreviewConfig.DEFAULT),
private val fetchMediaPreviewConfigResult: () -> Result<MediaPreviewConfig?> = { lambdaError() },
private val mediaPreviewConfigFlow: Flow<MediaPreviewConfig?> = flowOf(null),
private val getMediaPreviewValue: ()-> MediaPreviewValue? = { null },
private val getHideInviteAvatars: () -> Boolean = { false },
private val setMediaPreviewValueResult: (MediaPreviewValue) -> Result<Unit> = { lambdaError() },
private val setHideInviteAvatarsResult: (Boolean) -> Result<Unit> = { lambdaError() },
): MediaPreviewService {
) : MediaPreviewService {
override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = simulateLongTask {
fetchMediaPreviewConfigResult()
}
override fun getMediaPreviewConfigFlow(): Flow<MediaPreviewConfig?> {
return mediaPreviewConfigFlow
}
override suspend fun getMediaPreviewValue(): MediaPreviewValue? = simulateLongTask {
getMediaPreviewValue.invoke()
}
override suspend fun getHideInviteAvatars(): Boolean = simulateLongTask {
getHideInviteAvatars.invoke()
}
override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = simulateLongTask {
setMediaPreviewValueResult(mediaPreviewValue)
}

View File

@@ -22,9 +22,13 @@ interface AppPreferencesStore {
suspend fun setTheme(theme: String)
fun getThemeFlow(): Flow<String?>
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
suspend fun setHideInviteAvatars(hide: Boolean?)
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
fun getHideInviteAvatarsFlow(): Flow<Boolean?>
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?)
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?>
suspend fun setTracingLogLevel(logLevel: LogLevel)

View File

@@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.api.media.getMediaPreviewValue
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser

View File

@@ -43,8 +43,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent