Merge pull request #4962 from element-hq/feature/fga/csam_preferences_server

Change : sync moderation and safety preferences with server
This commit is contained in:
ganfra 2025-07-01 13:23:50 +02:00 committed by GitHub
commit 23f2804341
36 changed files with 1095 additions and 127 deletions

View file

@ -41,6 +41,7 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.loggedin.MediaPreviewConfigMigration
import io.element.android.appnav.loggedin.SendQueues import io.element.android.appnav.loggedin.SendQueues
import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
@ -123,6 +124,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val sendingQueue: SendQueues, private val sendingQueue: SendQueues,
private val logoutEntryPoint: LogoutEntryPoint, private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -188,6 +190,7 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope) loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService().setListener(verificationListener) matrixClient.sessionVerificationService().setListener(verificationListener)
mediaPreviewConfigMigration()
ftueService.state ftueService.state
.onEach { ftueState -> .onEach { ftueState ->

View file

@ -0,0 +1,58 @@
/*
* 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 io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* This migration is temporary, will be safe to remove after some time.
* The goal is to set the server config if it's not set, and remove the local data.
*/
class MediaPreviewConfigMigration @Inject constructor(
private val mediaPreviewService: MediaPreviewService,
private val appPreferencesStore: AppPreferencesStore,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) {
@Suppress("DEPRECATION")
operator fun invoke() = sessionCoroutineScope.launch {
val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first()
val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first()
if (hideInviteAvatars == null && mediaPreviewValue == null) {
// No local data, abort.
return@launch
}
mediaPreviewService
.fetchMediaPreviewConfig()
.onSuccess { config ->
if (config != null) {
appPreferencesStore.setHideInviteAvatars(null)
appPreferencesStore.setTimelineMediaPreviewValue(null)
} else {
if (hideInviteAvatars != null) {
mediaPreviewService.setHideInviteAvatars(hideInviteAvatars)
appPreferencesStore.setHideInviteAvatars(null)
}
if (mediaPreviewValue != null) {
mediaPreviewService.setMediaPreviewValue(mediaPreviewValue)
appPreferencesStore.setTimelineMediaPreviewValue(null)
}
}
}
.onFailure {
Timber.e(it, "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

@ -35,6 +35,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent.ShowConfirmation
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
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.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
@ -103,8 +104,11 @@ class RoomListPresenter @Inject constructor(
// Avatar indicator // Avatar indicator
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

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

@ -12,23 +12,26 @@ 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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.di.annotations.SessionCoroutineScope
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.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class AdvancedSettingsPresenter @Inject constructor( class AdvancedSettingsPresenter @Inject constructor(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<AdvancedSettingsState> { ) : Presenter<AdvancedSettingsState> {
@Composable @Composable
override fun present(): AdvancedSettingsState { override fun present(): AdvancedSettingsState {
val localCoroutineScope = rememberCoroutineScope()
val isDeveloperModeEnabled by remember { val isDeveloperModeEnabled by remember {
appPreferencesStore.isDeveloperModeEnabledFlow() appPreferencesStore.isDeveloperModeEnabledFlow()
}.collectAsState(initial = false) }.collectAsState(initial = false)
@ -41,13 +44,8 @@ class AdvancedSettingsPresenter @Inject constructor(
val theme = remember { val theme = remember {
appPreferencesStore.getThemeFlow().mapToTheme() appPreferencesStore.getThemeFlow().mapToTheme()
}.collectAsState(initial = Theme.System) }.collectAsState(initial = Theme.System)
val hideInviteAvatars by remember {
appPreferencesStore.getHideInviteAvatarsFlow()
}.collectAsState(false)
val timelineMediaPreviewValue by remember { val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
appPreferencesStore.getTimelineMediaPreviewValueFlow()
}.collectAsState(initial = MediaPreviewValue.On)
val themeOption by remember { val themeOption by remember {
derivedStateOf { derivedStateOf {
@ -61,28 +59,24 @@ class AdvancedSettingsPresenter @Inject constructor(
fun handleEvents(event: AdvancedSettingsEvents) { fun handleEvents(event: AdvancedSettingsEvents) {
when (event) { when (event) {
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> sessionCoroutineScope.launch {
appPreferencesStore.setDeveloperModeEnabled(event.enabled) appPreferencesStore.setDeveloperModeEnabled(event.enabled)
} }
is AdvancedSettingsEvents.SetSharePresenceEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetSharePresenceEnabled -> sessionCoroutineScope.launch {
sessionPreferencesStore.setSharePresence(event.enabled) sessionPreferencesStore.setSharePresence(event.enabled)
} }
is AdvancedSettingsEvents.SetCompressMedia -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetCompressMedia -> sessionCoroutineScope.launch {
sessionPreferencesStore.setCompressMedia(event.compress) sessionPreferencesStore.setCompressMedia(event.compress)
} }
is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetTheme -> sessionCoroutineScope.launch {
when (event.theme) { when (event.theme) {
ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name) ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name)
ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name) ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name)
ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name) ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name)
} }
} }
is AdvancedSettingsEvents.SetHideInviteAvatars -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
appPreferencesStore.setHideInviteAvatars(event.value) is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
}
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> localCoroutineScope.launch {
appPreferencesStore.setTimelineMediaPreviewValue(event.value)
}
} }
} }
@ -91,9 +85,8 @@ class AdvancedSettingsPresenter @Inject constructor(
isSharePresenceEnabled = isSharePresenceEnabled, isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia, doesCompressMedia = doesCompressMedia,
theme = themeOption, theme = themeOption,
hideInviteAvatars = hideInviteAvatars, mediaPreviewConfigState = mediaPreviewConfigState,
timelineMediaPreviewValue = timelineMediaPreviewValue, eventSink = ::handleEvents,
eventSink = { handleEvents(it) }
) )
} }
} }

View file

@ -11,7 +11,6 @@ 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.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(
@ -19,8 +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 eventSink: (AdvancedSettingsEvents) -> Unit val eventSink: (AdvancedSettingsEvents) -> Unit
) )

View file

@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> { open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
@ -18,7 +19,9 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
aAdvancedSettingsState(isSharePresenceEnabled = true), aAdvancedSettingsState(isSharePresenceEnabled = true),
aAdvancedSettingsState(doesCompressMedia = true), aAdvancedSettingsState(doesCompressMedia = true),
aAdvancedSettingsState(hideInviteAvatars = true), aAdvancedSettingsState(hideInviteAvatars = true),
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off) aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off),
aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading),
aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading),
) )
} }
@ -26,16 +29,22 @@ 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,
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (AdvancedSettingsEvents) -> Unit = {}, eventSink: (AdvancedSettingsEvents) -> Unit = {},
) = AdvancedSettingsState( ) = AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSharePresenceEnabled, isSharePresenceEnabled = isSharePresenceEnabled,
doesCompressMedia = doesCompressMedia, doesCompressMedia = doesCompressMedia,
theme = theme, theme = theme,
hideInviteAvatars = hideInviteAvatars, mediaPreviewConfigState = MediaPreviewConfigState(
timelineMediaPreviewValue = timelineMediaPreviewValue, hideInviteAvatars = hideInviteAvatars,
timelineMediaPreviewValue = timelineMediaPreviewValue,
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
setHideInviteAvatarsAction = setHideInviteAvatarsAction
),
eventSink = eventSink eventSink = eventSink
) )

View file

@ -7,7 +7,9 @@
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -27,6 +29,10 @@ import io.element.android.libraries.designsystem.theme.components.ListSectionHea
import io.element.android.libraries.designsystem.theme.components.ListSupportingText import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
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.compose.LocalAnalyticsService import io.element.android.services.analytics.compose.LocalAnalyticsService
@ -40,10 +46,21 @@ fun AdvancedSettingsView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val analyticsService = LocalAnalyticsService.current val analyticsService = LocalAnalyticsService.current
val snackbarDispatcher = LocalSnackbarDispatcher.current
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage)
PreferencePage( PreferencePage(
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_advanced_settings) title = stringResource(id = CommonStrings.common_advanced_settings),
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
}
) { ) {
PreferenceDropdown( PreferenceDropdown(
title = stringResource(id = CommonStrings.common_appearance), title = stringResource(id = CommonStrings.common_appearance),
@ -115,10 +132,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.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),
@ -132,24 +150,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.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.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.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
) )
} }
} }

View file

@ -0,0 +1,122 @@
/*
* 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 com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.SessionCoroutineScope
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.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
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)
@SingleIn(SessionScope::class)
class DefaultMediaPreviewConfigStateStore @Inject constructor(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val mediaPreviewService: MediaPreviewService,
private val snackbarDispatcher: SnackbarDispatcher,
) : MediaPreviewConfigStateStore {
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.mediaPreviewConfigFlow
val hideInviteAvatarsFlow = configFlow.map { it.hideInviteAvatar }.distinctUntilChanged()
val timelineMediaPreviewFlow = configFlow.map { it.mediaPreviewValue }.distinctUntilChanged()
hideInviteAvatarsFlow
.onEach {
Timber.d("Hide invite avatars changed to $it")
hideInviteAvatars.value = it
}
.launchIn(sessionCoroutineScope)
timelineMediaPreviewFlow
.onEach {
Timber.d("Timeline media preview value changed to $it")
timelineMediaPreviewValue.value = it
}
.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 {
val prevHideInviteAvatars = hideInviteAvatars.value
if (prevHideInviteAvatars == hide) return@launch
Timber.d("Setting hide invite avatars to $hide")
hideInviteAvatars.value = hide
runUpdatingState(setHideInviteAvatarsAction) {
mediaPreviewService
.setHideInviteAvatars(hide)
.onFailure {
hideInviteAvatars.value = prevHideInviteAvatars
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_something_went_wrong_message))
}
}
}
}
override fun setTimelineMediaPreviewValue(value: MediaPreviewValue) {
sessionCoroutineScope.launch {
val prevTimelineMediaPreviewValue = timelineMediaPreviewValue.value
if (prevTimelineMediaPreviewValue == value) return@launch
Timber.d("Setting timeline media preview value to $value")
timelineMediaPreviewValue.value = value
runUpdatingState(setTimelineMediaPreviewAction) {
mediaPreviewService
.setMediaPreviewValue(value)
.onFailure {
timelineMediaPreviewValue.value = prevTimelineMediaPreviewValue
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_something_went_wrong_message))
}
}
}
}
}

View file

@ -11,10 +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.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.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.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
@ -34,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)
} }
} }
} }
@ -124,49 +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 presenter = createAdvancedSettingsPresenter() val mediaPreviewStore = FakeMediaPreviewConfigStateStore(
setHideInviteAvatarsActionValue = AsyncAction.Loading,
setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit)
)
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(),
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
) = AdvancedSettingsPresenter( ) = AdvancedSettingsPresenter(
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
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 = "h1024dp") @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

@ -19,6 +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.MediaPreviewService
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
@ -72,6 +73,7 @@ interface MatrixClient {
fun notificationSettingsService(): NotificationSettingsService fun notificationSettingsService(): NotificationSettingsService
fun encryptionService(): EncryptionService fun encryptionService(): EncryptionService
fun roomDirectoryService(): RoomDirectoryService fun roomDirectoryService(): RoomDirectoryService
fun mediaPreviewService(): MediaPreviewService
suspend fun getCacheSize(): Long suspend fun getCacheSize(): Long
/** /**

View file

@ -0,0 +1,26 @@
/*
* 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.libraries.matrix.api.media
/**
* Configuration for media preview ie. invite avatars and timeline 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

@ -0,0 +1,35 @@
/*
* 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.libraries.matrix.api.media
import kotlinx.coroutines.flow.StateFlow
interface MediaPreviewService {
/**
* Will fetch the media preview config from the server.
*/
suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?>
/**
* Will emit the media preview config known by the client.
* This will emit a new value when received from sync.
*/
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

@ -29,6 +29,7 @@ fun MediaPreviewValue.isPreviewEnabled(joinRule: JoinRule?): Boolean {
On -> true On -> true
Off -> false Off -> false
Private -> when (joinRule) { Private -> when (joinRule) {
is JoinRule.Private,
is JoinRule.Knock, is JoinRule.Knock,
is JoinRule.Invite, is JoinRule.Invite,
is JoinRule.Restricted, is JoinRule.Restricted,

View file

@ -26,6 +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.MediaPreviewService
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
@ -52,6 +53,7 @@ import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.notification.RustNotificationService
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
import io.element.android.libraries.matrix.impl.oidc.toRustAction import io.element.android.libraries.matrix.impl.oidc.toRustAction
@ -214,6 +216,12 @@ class RustMatrixClient(
innerClient = innerClient, innerClient = innerClient,
) )
private val mediaPreviewService = RustMediaPreviewService(
sessionCoroutineScope = sessionCoroutineScope,
innerClient = innerClient,
sessionDispatcher = sessionDispatcher,
)
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow( private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
@ -507,6 +515,8 @@ class RustMatrixClient(
override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
override fun mediaPreviewService(): MediaPreviewService = mediaPreviewService
internal suspend fun destroy() { internal suspend fun destroy() {
innerNotificationClient.close() innerNotificationClient.close()

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
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.MediaPreviewService
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.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
@ -71,4 +72,9 @@ object SessionMatrixModule {
fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService { fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService {
return matrixClient.roomDirectoryService() return matrixClient.roomDirectoryService()
} }
@Provides
fun providesMediaPreviewService(matrixClient: MatrixClient): MediaPreviewService {
return matrixClient.mediaPreviewService()
}
} }

View file

@ -0,0 +1,88 @@
/*
* 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.libraries.matrix.impl.media
import io.element.android.libraries.core.extensions.runCatchingExceptions
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.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineDispatcher
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
import org.matrix.rustcomponents.sdk.MediaPreviewConfigListener
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 suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into())
}
}
override suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
val inviteAvatars = if (hide) InviteAvatars.OFF else InviteAvatars.ON
innerClient.setInviteAvatarsDisplayPolicy(inviteAvatars)
}
}
}
private fun RustMediaPreviewConfig.into(): MediaPreviewConfig {
return MediaPreviewConfig(
mediaPreviewValue = mediaPreviews.into(),
hideInviteAvatar = inviteAvatars == InviteAvatars.OFF
)
}
private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow {
subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener {
override fun onChange(mediaPreviewConfig: RustMediaPreviewConfig?) {
if (mediaPreviewConfig != null) {
trySend(mediaPreviewConfig.into())
}
}
})
}
private fun MediaPreviewValue.into(): MediaPreviews {
return when (this) {
MediaPreviewValue.On -> MediaPreviews.ON
MediaPreviewValue.Off -> MediaPreviews.OFF
MediaPreviewValue.Private -> MediaPreviews.PRIVATE
}
}
private fun MediaPreviews.into(): MediaPreviewValue {
return when (this) {
MediaPreviews.ON -> MediaPreviewValue.On
MediaPreviews.OFF -> MediaPreviewValue.Off
MediaPreviews.PRIVATE -> MediaPreviewValue.Private
}
}

View file

@ -18,6 +18,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.MediaPreviewService
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
@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
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.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.pushers.FakePushersService
@ -72,6 +74,7 @@ class FakeMatrixClient(
private val syncService: FakeSyncService = FakeSyncService(), private val syncService: FakeSyncService = FakeSyncService(),
private val encryptionService: FakeEncryptionService = FakeEncryptionService(), private val encryptionService: FakeEncryptionService = FakeEncryptionService(),
private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(), private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
private val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() }, private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = { private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success( Result.success(
@ -234,6 +237,7 @@ class FakeMatrixClient(
override fun notificationService(): NotificationService = notificationService override fun notificationService(): NotificationService = notificationService
override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService
override fun encryptionService(): EncryptionService = encryptionService override fun encryptionService(): EncryptionService = encryptionService
override fun mediaPreviewService(): MediaPreviewService = mediaPreviewService
override fun roomMembershipObserver(): RoomMembershipObserver { override fun roomMembershipObserver(): RoomMembershipObserver {
return RoomMembershipObserver() return RoomMembershipObserver()

View file

@ -0,0 +1,35 @@
/*
* 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.libraries.matrix.test.media
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.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
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 setMediaPreviewValueResult: (MediaPreviewValue) -> Result<Unit> = { lambdaError() },
private val setHideInviteAvatarsResult: (Boolean) -> Result<Unit> = { lambdaError() },
) : MediaPreviewService {
override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = simulateLongTask {
fetchMediaPreviewConfigResult()
}
override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = simulateLongTask {
setMediaPreviewValueResult(mediaPreviewValue)
}
override suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit> = simulateLongTask {
setHideInviteAvatarsResult(hide)
}
}

View file

@ -22,11 +22,14 @@ interface AppPreferencesStore {
suspend fun setTheme(theme: String) suspend fun setTheme(theme: String)
fun getThemeFlow(): Flow<String?> fun getThemeFlow(): Flow<String?>
suspend fun setHideInviteAvatars(value: Boolean) @Deprecated("Use MediaPreviewService instead. Kept only for migration.")
fun getHideInviteAvatarsFlow(): Flow<Boolean> suspend fun setHideInviteAvatars(hide: Boolean?)
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) fun getHideInviteAvatarsFlow(): Flow<Boolean?>
fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue> @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) suspend fun setTracingLogLevel(logLevel: LogLevel)
fun getTracingLogLevelFlow(): Flow<LogLevel> fun getTracingLogLevelFlow(): Flow<LogLevel>

View file

@ -85,27 +85,39 @@ class DefaultAppPreferencesStore @Inject constructor(
} }
} }
override suspend fun setHideInviteAvatars(value: Boolean) { @Deprecated("Use MediaPreviewService instead. Kept only for migration.")
store.edit { prefs -> override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
prefs[hideInviteAvatarsKey] = value
}
}
override fun getHideInviteAvatarsFlow(): Flow<Boolean> {
return store.data.map { prefs -> return store.data.map { prefs ->
prefs[hideInviteAvatarsKey] == true prefs[hideInviteAvatarsKey]
} }
} }
override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { @Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override suspend fun setHideInviteAvatars(hide: Boolean?) {
store.edit { prefs -> store.edit { prefs ->
prefs[timelineMediaPreviewValueKey] = value.name if (hide != null) {
prefs[hideInviteAvatarsKey] = hide
} else {
prefs.remove(hideInviteAvatarsKey)
}
} }
} }
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue> { @Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) {
store.edit { prefs ->
if (mediaPreviewValue != null) {
prefs[timelineMediaPreviewValueKey] = mediaPreviewValue.name
} else {
prefs.remove(timelineMediaPreviewValueKey)
}
}
}
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?> {
return store.data.map { prefs -> return store.data.map { prefs ->
prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } ?: MediaPreviewValue.On prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) }
} }
} }

View file

@ -17,8 +17,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryAppPreferencesStore( class InMemoryAppPreferencesStore(
isDeveloperModeEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false,
customElementCallBaseUrl: String? = null, customElementCallBaseUrl: String? = null,
hideInviteAvatars: Boolean = false, hideInviteAvatars: Boolean? = null,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, timelineMediaPreviewValue: MediaPreviewValue? = null,
theme: String? = null, theme: String? = null,
logLevel: LogLevel = LogLevel.INFO, logLevel: LogLevel = LogLevel.INFO,
traceLockPacks: Set<TraceLogPack> = emptySet(), traceLockPacks: Set<TraceLogPack> = emptySet(),
@ -55,20 +55,24 @@ class InMemoryAppPreferencesStore(
return theme return theme
} }
override suspend fun setHideInviteAvatars(value: Boolean) { @Deprecated("Use MediaPreviewService instead. Kept only for migration.")
hideInviteAvatars.value = value override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
}
override fun getHideInviteAvatarsFlow(): Flow<Boolean> {
return hideInviteAvatars return hideInviteAvatars
} }
override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { @Deprecated("Use MediaPreviewService instead. Kept only for migration.")
timelineMediaPreviewValue.value = value override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?> {
return timelineMediaPreviewValue
} }
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue> { @Deprecated("Use MediaPreviewService instead. Kept only for migration.")
return timelineMediaPreviewValue override suspend fun setHideInviteAvatars(hide: Boolean?) {
hideInviteAvatars.value = hide
}
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) {
timelineMediaPreviewValue.value = mediaPreviewValue
} }
override suspend fun setTracingLogLevel(logLevel: LogLevel) { override 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
@ -41,7 +42,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
@ -50,7 +50,6 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.flow.first
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -79,7 +78,6 @@ class DefaultNotifiableEventResolver @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver, private val callNotificationEventResolver: CallNotificationEventResolver,
private val appPreferencesStore: AppPreferencesStore,
) : NotifiableEventResolver { ) : NotifiableEventResolver {
override suspend fun resolveEvents( override suspend fun resolveEvents(
sessionId: SessionId, sessionId: SessionId,
@ -117,6 +115,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
): Result<ResolvedPushEvent> = runCatchingExceptions { ): Result<ResolvedPushEvent> = runCatchingExceptions {
when (val content = this.content) { when (val content = this.content) {
is NotificationContent.MessageLike.RoomMessage -> { is NotificationContent.MessageLike.RoomMessage -> {
val showMediaPreview = client.mediaPreviewService().getMediaPreviewValue() == MediaPreviewValue.On
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId) val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName) val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName)
val notifiableMessageEvent = buildNotifiableMessageEvent( val notifiableMessageEvent = buildNotifiableMessageEvent(
@ -129,8 +128,8 @@ class DefaultNotifiableEventResolver @Inject constructor(
timestamp = this.timestamp, timestamp = this.timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
body = messageBody, body = messageBody,
imageUriString = content.fetchImageIfPresent(client)?.toString(), imageUriString = if (showMediaPreview) content.fetchImageIfPresent(client)?.toString() else null,
imageMimeType = content.getImageMimetype(), imageMimeType = if (showMediaPreview) content.getImageMimetype() else null,
roomName = roomDisplayName, roomName = roomDisplayName,
roomIsDm = isDm, roomIsDm = isDm,
roomAvatarPath = roomAvatarUrl, roomAvatarPath = roomAvatarUrl,
@ -323,9 +322,6 @@ class DefaultNotifiableEventResolver @Inject constructor(
} }
private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? { private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? {
if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) {
return null
}
val fileResult = when (val messageType = messageType) { val fileResult = when (val messageType = messageType) {
is ImageMessageType -> notificationMediaRepoFactory.create(client) is ImageMessageType -> notificationMediaRepoFactory.create(client)
.getMediaFile( .getMediaFile(
@ -349,10 +345,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
.getOrNull() .getOrNull()
} }
private suspend fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? { private fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? {
if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) {
return null
}
return when (val messageType = messageType) { return when (val messageType = messageType) {
is ImageMessageType -> messageType.info?.mimetype is ImageMessageType -> messageType.info?.mimetype
is VideoMessageType -> null // Use the thumbnail here? is VideoMessageType -> null // Use the thumbnail here?

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
@ -826,7 +824,6 @@ class DefaultNotifiableEventResolverTest {
private fun createDefaultNotifiableEventResolver( private fun createDefaultNotifiableEventResolver(
notificationService: FakeNotificationService? = FakeNotificationService(), notificationService: FakeNotificationService? = FakeNotificationService(),
notificationResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap()), notificationResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap()),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(), callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
): DefaultNotifiableEventResolver { ): DefaultNotifiableEventResolver {
val context = RuntimeEnvironment.getApplication() as Context val context = RuntimeEnvironment.getApplication() as Context
@ -849,7 +846,6 @@ class DefaultNotifiableEventResolverTest {
context = context, context = context,
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),
callNotificationEventResolver = callNotificationEventResolver, callNotificationEventResolver = callNotificationEventResolver,
appPreferencesStore = appPreferencesStore,
) )
} }
} }

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e639d6e0f93e15f3ea39a0e0d9561a8da485abcc97a92b7370d33f2b701c40bb
size 46602

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:264e12f555c641960f0fef0c1177715fa5d596f7a42e0796d49533b68cfc88b9
size 46256

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9b797e153358ac8eb1e226ad537c23c61f4dd97ab8d5947b98bdd9b193f369b7
size 48487

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3853ed9a82dfa5bdf5f0e4d79e7af04bc7e43531e691c4d5fb17a37f5bd4f5b4
size 48249