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 @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
) { ) {
@Suppress("DEPRECATION")
operator fun invoke() = sessionCoroutineScope.launch { operator fun invoke() = sessionCoroutineScope.launch {
val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first() val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first()
val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first() val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first()
@ -49,7 +50,8 @@ class MediaPreviewConfigMigration @Inject constructor(
appPreferencesStore.setTimelineMediaPreviewValue(null) appPreferencesStore.setTimelineMediaPreviewValue(null)
} }
} }
}.onFailure { }
.onFailure {
Timber.d("Couldn't perform migration, failed to fetch media preview config.") 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.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState 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.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient 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.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.join.JoinRule
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.ui.model.toInviteSender 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.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Optional import java.util.Optional
@ -67,7 +67,6 @@ class JoinRoomPresenter @AssistedInject constructor(
private val forgetRoom: ForgetRoom, private val forgetRoom: ForgetRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
) : Presenter<JoinRoomState> { ) : Presenter<JoinRoomState> {
interface Factory { interface Factory {
@ -94,8 +93,11 @@ class JoinRoomPresenter @AssistedInject constructor(
var knockMessage by rememberSaveable { mutableStateOf("") } var knockMessage by rememberSaveable { mutableStateOf("") }
var isDismissingContent by remember { mutableStateOf(false) } var isDismissingContent by remember { mutableStateOf(false) }
val hideInviteAvatars by remember { val hideInviteAvatars by remember {
appPreferencesStore.getHideInviteAvatarsFlow() matrixClient
}.collectAsState(initial = false) .mediaPreviewService()
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() } val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() }
val contentState by produceState<ContentState>( 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.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import java.util.Optional import java.util.Optional
@Module @Module
@ -37,7 +36,6 @@ object JoinRoomModule {
forgetRoom: ForgetRoom, forgetRoom: ForgetRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta, buildMeta: BuildMeta,
appPreferencesStore: AppPreferencesStore,
seenInvitesStore: SeenInvitesStore, seenInvitesStore: SeenInvitesStore,
): JoinRoomPresenter.Factory { ): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory { return object : JoinRoomPresenter.Factory {
@ -61,7 +59,6 @@ object JoinRoomModule {
cancelKnockRoom = cancelKnockRoom, cancelKnockRoom = cancelKnockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta, buildMeta = buildMeta,
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore, 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.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.matrix.ui.model.toInviteSender 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.WarmUpRule
import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
@ -1057,7 +1055,6 @@ class JoinRoomPresenterTest {
forgetRoom: ForgetRoom = FakeForgetRoom(), forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter { ): JoinRoomPresenter {
return JoinRoomPresenter( return JoinRoomPresenter(
@ -1073,7 +1070,6 @@ class JoinRoomPresenterTest {
forgetRoom = forgetRoom, forgetRoom = forgetRoom,
buildMeta = buildMeta, buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
) )
} }

View file

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

View file

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

View file

@ -8,21 +8,15 @@
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme 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.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -51,6 +45,8 @@ class AdvancedSettingsPresenter @Inject constructor(
appPreferencesStore.getThemeFlow().mapToTheme() appPreferencesStore.getThemeFlow().mapToTheme()
}.collectAsState(initial = Theme.System) }.collectAsState(initial = Theme.System)
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
val themeOption by remember { val themeOption by remember {
derivedStateOf { derivedStateOf {
when (theme.value) { when (theme.value) {
@ -89,10 +85,7 @@ class AdvancedSettingsPresenter @Inject constructor(
isSharePresenceEnabled = isSharePresenceEnabled, isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia, doesCompressMedia = doesCompressMedia,
theme = themeOption, theme = themeOption,
hideInviteAvatars = mediaPreviewConfigStateStore.hideInviteAvatars.value, mediaPreviewConfigState = mediaPreviewConfigState,
timelineMediaPreviewValue = mediaPreviewConfigStateStore.timelineMediaPreviewValue.value,
setHideInviteAvatarsAction = mediaPreviewConfigStateStore.setHideInviteAvatarsAction.value,
setTimelineMediaPreviewAction = mediaPreviewConfigStateStore.setTimelineMediaPreviewAction.value,
eventSink = ::handleEvents, 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.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.res.stringResource 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.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
data class AdvancedSettingsState( data class AdvancedSettingsState(
@ -20,10 +18,7 @@ data class AdvancedSettingsState(
val isSharePresenceEnabled: Boolean, val isSharePresenceEnabled: Boolean,
val doesCompressMedia: Boolean, val doesCompressMedia: Boolean,
val theme: ThemeOption, val theme: ThemeOption,
val hideInviteAvatars: Boolean, val mediaPreviewConfigState: MediaPreviewConfigState,
val timelineMediaPreviewValue: MediaPreviewValue,
val setHideInviteAvatarsAction: AsyncAction<Unit>,
val setTimelineMediaPreviewAction: AsyncAction<Unit>,
val eventSink: (AdvancedSettingsEvents) -> Unit val eventSink: (AdvancedSettingsEvents) -> Unit
) )

View file

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

View file

@ -133,11 +133,11 @@ private fun ModerationAndSafety(
) { ) {
PreferenceSwitch( PreferenceSwitch(
title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title),
isChecked = state.hideInviteAvatars, isChecked = state.mediaPreviewConfigState.hideInviteAvatars,
onCheckedChange = { onCheckedChange = {
state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it)) state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it))
}, },
enabled = !state.setHideInviteAvatarsAction.isLoading() enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading()
) )
ListSectionHeader( ListSectionHeader(
title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title),
@ -153,27 +153,36 @@ private fun ModerationAndSafety(
) )
ListItem( ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, 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 = { onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
}, },
enabled = !state.setTimelineMediaPreviewAction.isLoading() enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
) )
ListItem( ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, 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 = { onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
}, },
enabled = !state.setTimelineMediaPreviewAction.isLoading() enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
) )
ListItem( ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, 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 = { onClick = {
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) 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 package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.State import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.AsyncAction 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.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
interface MediaPreviewConfigStateStore { data class MediaPreviewConfigState(
val hideInviteAvatars: State<Boolean> val hideInviteAvatars: Boolean,
val timelineMediaPreviewValue: State<MediaPreviewValue> val timelineMediaPreviewValue: MediaPreviewValue,
val setHideInviteAvatarsAction: State<AsyncAction<Unit>> val setHideInviteAvatarsAction: AsyncAction<Unit>,
val setTimelineMediaPreviewAction: State<AsyncAction<Unit>> val setTimelineMediaPreviewAction: AsyncAction<Unit>,
)
interface MediaPreviewConfigStateStore {
@Composable
fun state(): MediaPreviewConfigState
fun setHideInviteAvatars(hide: Boolean) fun setHideInviteAvatars(hide: Boolean)
fun setTimelineMediaPreviewValue(value: MediaPreviewValue) fun setTimelineMediaPreviewValue(value: MediaPreviewValue)
} }
@ContributesBinding(SessionScope::class, boundType = MediaPreviewConfigStateStore::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class DefaultMediaPreviewConfigStateStore @Inject constructor( class DefaultMediaPreviewConfigStateStore @Inject constructor(
@SessionCoroutineScope @SessionCoroutineScope
@ -49,19 +51,19 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor(
private val mediaPreviewService: MediaPreviewService, private val mediaPreviewService: MediaPreviewService,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
) : MediaPreviewConfigStateStore { ) : MediaPreviewConfigStateStore {
override val hideInviteAvatars = mutableStateOf(false) private val hideInviteAvatars = mutableStateOf(false)
override val timelineMediaPreviewValue = mutableStateOf(MediaPreviewValue.On) private val timelineMediaPreviewValue = mutableStateOf(MediaPreviewValue.On)
override val setHideInviteAvatarsAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) private val setHideInviteAvatarsAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
override val setTimelineMediaPreviewAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) private val setTimelineMediaPreviewAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
init { init {
val configFlow = mediaPreviewService.getMediaPreviewConfigFlow().shareIn(sessionCoroutineScope, SharingStarted.Eagerly) val configFlow = mediaPreviewService.mediaPreviewConfigFlow
val hideInviteAvatarsFlow = configFlow.mapNotNull { it?.hideInviteAvatar }.distinctUntilChanged() val hideInviteAvatarsFlow = configFlow.map { it.hideInviteAvatar }.distinctUntilChanged()
val timelineMediaPreviewFlow = configFlow.mapNotNull { it?.mediaPreviewValue }.distinctUntilChanged() val timelineMediaPreviewFlow = configFlow.map { it.mediaPreviewValue }.distinctUntilChanged()
hideInviteAvatarsFlow hideInviteAvatarsFlow
.onEach { .onEach {
Timber.d("Hide invi@te avatars changed to $it") Timber.d("Hide invite avatars changed to $it")
hideInviteAvatars.value = it hideInviteAvatars.value = it
} }
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
@ -74,6 +76,16 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor(
.launchIn(sessionCoroutineScope) .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) { override fun setHideInviteAvatars(hide: Boolean) {
sessionCoroutineScope.launch { sessionCoroutineScope.launch {
Timber.d("Setting hide invite avatars to $hide") 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.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 io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.architecture.AsyncAction
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.MediaPreviewValue
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -38,6 +36,10 @@ class AdvancedSettingsPresenterTest {
assertThat(isSharePresenceEnabled).isTrue() assertThat(isSharePresenceEnabled).isTrue()
assertThat(doesCompressMedia).isTrue() assertThat(doesCompressMedia).isTrue()
assertThat(theme).isEqualTo(ThemeOption.System) 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 @Test
fun `present - hide invite avatars`() = runTest { fun `present - hide invite avatars`() = runTest {
val presenter = createAdvancedSettingsPresenter() val mediaPreviewStore = FakeMediaPreviewConfigStateStore()
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
with(awaitItem()) { with(awaitItem()) {
assertThat(hideInviteAvatars).isFalse() assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true)) eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true))
} }
with(awaitItem()) { with(awaitItem()) {
assertThat(hideInviteAvatars).isTrue() assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(false)) eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(false))
} }
with(awaitItem()) { 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 @Test
fun `present - timeline media preview value`() = runTest { fun `present - async actions state`() = runTest {
val mediaPreviewConfigFlow = MutableStateFlow<MediaPreviewConfig?>(null) val mediaPreviewStore = FakeMediaPreviewConfigStateStore(
val presenter = createAdvancedSettingsPresenter( setHideInviteAvatarsActionValue = AsyncAction.Loading,
matrixClient = FakeMatrixClient( setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit)
mediaPreviewConfigFlow = mediaPreviewConfigFlow
)
) )
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
with(awaitItem()) { with(awaitItem()) {
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading)
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit))
}
with(awaitItem()) {
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
}
with(awaitItem()) {
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
} }
} }
} }
private fun createAdvancedSettingsPresenter( private fun CoroutineScope.createAdvancedSettingsPresenter(
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
matrixClient: MatrixClient = FakeMatrixClient(), mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
) = AdvancedSettingsPresenter( ) = AdvancedSettingsPresenter(
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
sessionPreferencesStore = sessionPreferencesStore, 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 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.preferences.impl.R 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.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -131,7 +132,7 @@ class AdvancedSettingsViewTest {
} }
@Test @Test
@Config(qualifiers = "h640dp") @Config(qualifiers = "h1080dp")
fun `clicking on hide invite avatars emits the expected event`() { fun `clicking on hide invite avatars emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( rule.setAdvancedSettingsView(
@ -145,8 +146,8 @@ class AdvancedSettingsViewTest {
} }
@Test @Test
@Config(qualifiers = "h640dp") @Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview emits the expected event`() { fun `clicking on timeline media preview always hide emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( rule.setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
@ -157,6 +158,65 @@ class AdvancedSettingsViewTest {
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) 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( 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.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter 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.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -120,8 +121,11 @@ class RoomListPresenter @Inject constructor(
// Avatar indicator // Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
val hideInvitesAvatar by remember { val hideInvitesAvatar by remember {
appPreferencesStore.getHideInviteAvatarsFlow() client
}.collectAsState(initial = false) .mediaPreviewService()
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) } val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }

View file

@ -8,7 +8,6 @@
package io.element.android.libraries.matrix.api package io.element.android.libraries.matrix.api
import io.element.android.libraries.core.data.tryOrNull 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.DeviceId
import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.ProgressCallback 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.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.encryption.EncryptionService 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.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.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.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction 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.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import java.util.Optional import java.util.Optional
interface MatrixClient { interface MatrixClient {
@ -175,7 +171,6 @@ interface MatrixClient {
* Return true if Livekit Rtc is supported, i.e. if Element Call is available. * Return true if Livekit Rtc is supported, i.e. if Element Call is available.
*/ */
suspend fun isLivekitRtcSupported(): Boolean suspend fun isLivekitRtcSupported(): Boolean
} }
/** /**

View file

@ -13,4 +13,14 @@ package io.element.android.libraries.matrix.api.media
data class MediaPreviewConfig( data class MediaPreviewConfig(
val mediaPreviewValue: MediaPreviewValue, val mediaPreviewValue: MediaPreviewValue,
val hideInviteAvatar: Boolean, 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 package io.element.android.libraries.matrix.api.media
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow
interface MediaPreviewService { interface MediaPreviewService {
/** /**
@ -19,24 +19,17 @@ interface MediaPreviewService {
* Will emit the media preview config known by the client. * Will emit the media preview config known by the client.
* This will emit a new value when received from sync. * This will emit a new value when received from sync.
*/ */
fun getMediaPreviewConfigFlow(): Flow<MediaPreviewConfig?> val mediaPreviewConfigFlow: StateFlow<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
/** /**
* Set the media preview display policy. This will update the value on the server and update the local value when successful. * 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> 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. * 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> 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.createroom.RoomPreset
import io.element.android.libraries.matrix.api.encryption.EncryptionService 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.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.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.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction 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.Client
import org.matrix.rustcomponents.sdk.ClientException import org.matrix.rustcomponents.sdk.ClientException
import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.InviteAvatars
import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomInfoListener
@ -220,6 +217,7 @@ class RustMatrixClient(
) )
private val mediaPreviewService = RustMediaPreviewService( private val mediaPreviewService = RustMediaPreviewService(
sessionCoroutineScope = sessionCoroutineScope,
innerClient = innerClient, innerClient = innerClient,
sessionDispatcher = sessionDispatcher, sessionDispatcher = sessionDispatcher,
) )
@ -694,8 +692,6 @@ class RustMatrixClient(
innerClient.isLivekitRtcSupported() innerClient.isLivekitRtcSupported()
} }
private suspend fun File.getCacheSize( private suspend fun File.getCacheSize(
includeCryptoDb: Boolean = false, includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) { ): 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.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineDispatcher 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 kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.InviteAvatars import org.matrix.rustcomponents.sdk.InviteAvatars
@ -22,27 +25,27 @@ import org.matrix.rustcomponents.sdk.MediaPreviews
import org.matrix.rustcomponents.sdk.MediaPreviewConfig as RustMediaPreviewConfig import org.matrix.rustcomponents.sdk.MediaPreviewConfig as RustMediaPreviewConfig
class RustMediaPreviewService( class RustMediaPreviewService(
sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher, private val sessionDispatcher: CoroutineDispatcher,
private val innerClient: Client, private val innerClient: Client,
) : MediaPreviewService { ) : MediaPreviewService {
override val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig> =
innerClient
.getMediaPreviewConfigFlow()
.stateIn(sessionCoroutineScope, started = SharingStarted.Lazily, initialValue = MediaPreviewConfig.DEFAULT)
override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = withContext(sessionDispatcher) { override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = withContext(sessionDispatcher) {
runCatchingExceptions { runCatchingExceptions {
innerClient.fetchMediaPreviewConfig()?.into() 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) { override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions { runCatchingExceptions {
innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into()) innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into())
} }
} }
override suspend fun getHideInviteAvatars(): Boolean = innerClient.getInviteAvatarsDisplayPolicy() == InviteAvatars.OFF
override suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit> = withContext(sessionDispatcher) { override suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions { runCatchingExceptions {
val inviteAvatars = if (hide) InviteAvatars.OFF else InviteAvatars.ON val inviteAvatars = if (hide) InviteAvatars.OFF else InviteAvatars.ON
@ -61,7 +64,9 @@ private fun RustMediaPreviewConfig.into(): MediaPreviewConfig {
private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow { private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow {
subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener { subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener {
override fun onChange(mediaPreviewConfig: RustMediaPreviewConfig?) { 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 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.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters 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.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.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
@ -96,7 +94,6 @@ class FakeMatrixClient(
private val canReportRoomLambda: () -> Boolean = { false }, private val canReportRoomLambda: () -> Boolean = { false },
private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()), override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
) : MatrixClient { ) : MatrixClient {
var setDisplayNameCalled: Boolean = false var setDisplayNameCalled: Boolean = false
private set private set
@ -246,7 +243,6 @@ class FakeMatrixClient(
return RoomMembershipObserver() return RoomMembershipObserver()
} }
// Mocks // Mocks
fun givenCreateRoomResult(result: Result<RoomId>) { 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.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.StateFlow
class FakeMediaPreviewService( class FakeMediaPreviewService(
override val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig> = MutableStateFlow(MediaPreviewConfig.DEFAULT),
private val fetchMediaPreviewConfigResult: () -> Result<MediaPreviewConfig?> = { lambdaError() }, 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 setMediaPreviewValueResult: (MediaPreviewValue) -> Result<Unit> = { lambdaError() },
private val setHideInviteAvatarsResult: (Boolean) -> Result<Unit> = { lambdaError() }, private val setHideInviteAvatarsResult: (Boolean) -> Result<Unit> = { lambdaError() },
): MediaPreviewService { ) : MediaPreviewService {
override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = simulateLongTask { override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = simulateLongTask {
fetchMediaPreviewConfigResult() 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 { override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = simulateLongTask {
setMediaPreviewValueResult(mediaPreviewValue) setMediaPreviewValueResult(mediaPreviewValue)
} }

View file

@ -22,9 +22,13 @@ interface AppPreferencesStore {
suspend fun setTheme(theme: String) suspend fun setTheme(theme: String)
fun getThemeFlow(): Flow<String?> fun getThemeFlow(): Flow<String?>
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
suspend fun setHideInviteAvatars(hide: Boolean?) suspend fun setHideInviteAvatars(hide: Boolean?)
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
fun getHideInviteAvatarsFlow(): Flow<Boolean?> fun getHideInviteAvatarsFlow(): Flow<Boolean?>
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?)
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?> fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?>
suspend fun setTracingLogLevel(logLevel: LogLevel) 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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId 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.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.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser 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.FakeNotificationService
import io.element.android.libraries.matrix.test.notification.aNotificationData import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser 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.fake.FakeNotificationMediaRepo
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent