diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index aa33641318..043dc75a92 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -41,6 +41,7 @@ import dagger.assisted.AssistedInject import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.anvilannotations.ContributesNode 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.room.RoomFlowNode import io.element.android.appnav.room.RoomNavigationTarget @@ -123,6 +124,7 @@ class LoggedInFlowNode @AssistedInject constructor( private val sendingQueue: SendQueues, private val logoutEntryPoint: LogoutEntryPoint, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, + private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, snackbarDispatcher: SnackbarDispatcher, ) : BaseFlowNode( backstack = BackStack( @@ -188,6 +190,7 @@ class LoggedInFlowNode @AssistedInject constructor( appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) loggedInFlowProcessor.observeEvents(sessionCoroutineScope) matrixClient.sessionVerificationService().setListener(verificationListener) + mediaPreviewConfigMigration() ftueService.state .onEach { ftueState -> diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt new file mode 100644 index 0000000000..d9ed15318a --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt @@ -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.") + } + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt new file mode 100644 index 0000000000..e6a17b440f --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt @@ -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 + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index dd7ada703d..45e4dd4238 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -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.libraries.architecture.AsyncData 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.FeatureFlags import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState @@ -103,8 +104,11 @@ class RoomListPresenter @Inject constructor( // Avatar indicator val hideInvitesAvatar by remember { - appPreferencesStore.getHideInviteAvatarsFlow() - }.collectAsState(initial = false) + client + .mediaPreviewService() + .mediaPreviewConfigFlow + .mapState { config -> config.hideInviteAvatar } + }.collectAsState() val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt index e8514ac4a5..6206c2426a 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -34,6 +34,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -49,7 +50,6 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.ui.model.toInviteSender -import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Optional @@ -67,7 +67,6 @@ class JoinRoomPresenter @AssistedInject constructor( private val forgetRoom: ForgetRoom, private val acceptDeclineInvitePresenter: Presenter, private val buildMeta: BuildMeta, - private val appPreferencesStore: AppPreferencesStore, private val seenInvitesStore: SeenInvitesStore, ) : Presenter { interface Factory { @@ -94,8 +93,11 @@ class JoinRoomPresenter @AssistedInject constructor( var knockMessage by rememberSaveable { mutableStateOf("") } var isDismissingContent by remember { mutableStateOf(false) } val hideInviteAvatars by remember { - appPreferencesStore.getHideInviteAvatarsFlow() - }.collectAsState(initial = false) + matrixClient + .mediaPreviewService() + .mediaPreviewConfigFlow + .mapState { config -> config.hideInviteAvatar } + }.collectAsState() val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() } val contentState by produceState( diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt index 7142133eb1..5e85c0abbc 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt @@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.room.join.JoinRoom -import io.element.android.libraries.preferences.api.store.AppPreferencesStore import java.util.Optional @Module @@ -37,7 +36,6 @@ object JoinRoomModule { forgetRoom: ForgetRoom, acceptDeclineInvitePresenter: Presenter, buildMeta: BuildMeta, - appPreferencesStore: AppPreferencesStore, seenInvitesStore: SeenInvitesStore, ): JoinRoomPresenter.Factory { return object : JoinRoomPresenter.Factory { @@ -61,7 +59,6 @@ object JoinRoomModule { cancelKnockRoom = cancelKnockRoom, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, buildMeta = buildMeta, - appPreferencesStore = appPreferencesStore, seenInvitesStore = seenInvitesStore, ) } diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt index ac716a4203..8a475668ae 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt @@ -52,8 +52,6 @@ import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.toInviteSender -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.assert @@ -1057,7 +1055,6 @@ class JoinRoomPresenterTest { forgetRoom: ForgetRoom = FakeForgetRoom(), buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, - appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), ): JoinRoomPresenter { return JoinRoomPresenter( @@ -1073,7 +1070,6 @@ class JoinRoomPresenterTest { forgetRoom = forgetRoom, buildMeta = buildMeta, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, - appPreferencesStore = appPreferencesStore, seenInvitesStore = seenInvitesStore, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt index 00cde0f880..b4c2576a65 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -14,16 +14,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.isPreviewEnabled import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.collections.immutable.toImmutableSet import javax.inject.Inject class TimelineProtectionPresenter @Inject constructor( - private val appPreferencesStore: AppPreferencesStore, + private val mediaPreviewService: MediaPreviewService, private val room: BaseRoom, ) : Presenter { private val allowedEvents = mutableStateOf>(setOf()) @@ -31,8 +31,8 @@ class TimelineProtectionPresenter @Inject constructor( @Composable override fun present(): TimelineProtectionState { val mediaPreviewValue = remember { - appPreferencesStore.getTimelineMediaPreviewValueFlow() - }.collectAsState(initial = MediaPreviewValue.On) + mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue } + }.collectAsState() val roomInfo = room.roomInfoFlow.collectAsState() val protectionState by remember { derivedStateOf { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt index ac8e2a5290..49f30716a8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt @@ -8,17 +8,19 @@ package io.element.android.features.messages.impl.timeline.protection import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.aRoomInfo -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -38,10 +40,10 @@ class TimelineProtectionPresenterTest { @Test fun `present - media preview value off`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Off) - val presenter = createPresenter(appPreferencesStore) + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) + val presenter = createPresenter(mediaPreviewService = mediaPreviewService) presenter.test { - skipItems(1) val initialState = awaitItem() assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf())) // ShowContent with null should have no effect. @@ -54,11 +56,11 @@ class TimelineProtectionPresenterTest { @Test fun `present - media preview value private in public room`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private) + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Public)) - val presenter = createPresenter(appPreferencesStore, room) + val presenter = createPresenter(mediaPreviewService = mediaPreviewService, room = room) presenter.test { - skipItems(1) val initialState = awaitItem() assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf())) // ShowContent with null should have no effect. @@ -71,9 +73,10 @@ class TimelineProtectionPresenterTest { @Test fun `present - media preview value private in non public room`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private) + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite)) - val presenter = createPresenter(appPreferencesStore, room) + val presenter = createPresenter(mediaPreviewService = mediaPreviewService, room = room) presenter.test { val initialState = awaitItem() assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll) @@ -84,10 +87,10 @@ class TimelineProtectionPresenterTest { } private fun createPresenter( - appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), room: BaseRoom = FakeBaseRoom(), + mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), ) = TimelineProtectionPresenter( - appPreferencesStore = appPreferencesStore, + mediaPreviewService = mediaPreviewService, room = room, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 41ac9bb0f4..be5f11116c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -12,23 +12,26 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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.mapToTheme 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.SessionPreferencesStore +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject class AdvancedSettingsPresenter @Inject constructor( private val appPreferencesStore: AppPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore, + private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, ) : Presenter { @Composable override fun present(): AdvancedSettingsState { - val localCoroutineScope = rememberCoroutineScope() val isDeveloperModeEnabled by remember { appPreferencesStore.isDeveloperModeEnabledFlow() }.collectAsState(initial = false) @@ -41,13 +44,8 @@ class AdvancedSettingsPresenter @Inject constructor( val theme = remember { appPreferencesStore.getThemeFlow().mapToTheme() }.collectAsState(initial = Theme.System) - val hideInviteAvatars by remember { - appPreferencesStore.getHideInviteAvatarsFlow() - }.collectAsState(false) - val timelineMediaPreviewValue by remember { - appPreferencesStore.getTimelineMediaPreviewValueFlow() - }.collectAsState(initial = MediaPreviewValue.On) + val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() val themeOption by remember { derivedStateOf { @@ -61,28 +59,24 @@ class AdvancedSettingsPresenter @Inject constructor( fun handleEvents(event: AdvancedSettingsEvents) { when (event) { - is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { + is AdvancedSettingsEvents.SetDeveloperModeEnabled -> sessionCoroutineScope.launch { appPreferencesStore.setDeveloperModeEnabled(event.enabled) } - is AdvancedSettingsEvents.SetSharePresenceEnabled -> localCoroutineScope.launch { + is AdvancedSettingsEvents.SetSharePresenceEnabled -> sessionCoroutineScope.launch { sessionPreferencesStore.setSharePresence(event.enabled) } - is AdvancedSettingsEvents.SetCompressMedia -> localCoroutineScope.launch { + is AdvancedSettingsEvents.SetCompressMedia -> sessionCoroutineScope.launch { sessionPreferencesStore.setCompressMedia(event.compress) } - is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch { + is AdvancedSettingsEvents.SetTheme -> sessionCoroutineScope.launch { when (event.theme) { ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name) ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name) ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name) } } - is AdvancedSettingsEvents.SetHideInviteAvatars -> localCoroutineScope.launch { - appPreferencesStore.setHideInviteAvatars(event.value) - } - is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> localCoroutineScope.launch { - appPreferencesStore.setTimelineMediaPreviewValue(event.value) - } + is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value) + is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value) } } @@ -91,9 +85,8 @@ class AdvancedSettingsPresenter @Inject constructor( isSharePresenceEnabled = isSharePresenceEnabled, doesCompressMedia = doesCompressMedia, theme = themeOption, - hideInviteAvatars = hideInviteAvatars, - timelineMediaPreviewValue = timelineMediaPreviewValue, - eventSink = { handleEvents(it) } + mediaPreviewConfigState = mediaPreviewConfigState, + eventSink = ::handleEvents, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 58f93fc665..ef41ab26b3 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.res.stringResource import io.element.android.libraries.designsystem.components.preferences.DropdownOption -import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.ui.strings.CommonStrings data class AdvancedSettingsState( @@ -19,8 +18,7 @@ data class AdvancedSettingsState( val isSharePresenceEnabled: Boolean, val doesCompressMedia: Boolean, val theme: ThemeOption, - val hideInviteAvatars: Boolean, - val timelineMediaPreviewValue: MediaPreviewValue, + val mediaPreviewConfigState: MediaPreviewConfigState, val eventSink: (AdvancedSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index b0b9ed34fd..42e63134d1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -8,6 +8,7 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.media.MediaPreviewValue open class AdvancedSettingsStateProvider : PreviewParameterProvider { @@ -18,7 +19,9 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized, + setHideInviteAvatarsAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (AdvancedSettingsEvents) -> Unit = {}, ) = AdvancedSettingsState( isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSharePresenceEnabled, doesCompressMedia = doesCompressMedia, theme = theme, - hideInviteAvatars = hideInviteAvatars, - timelineMediaPreviewValue = timelineMediaPreviewValue, + mediaPreviewConfigState = MediaPreviewConfigState( + hideInviteAvatars = hideInviteAvatars, + timelineMediaPreviewValue = timelineMediaPreviewValue, + setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, + setHideInviteAvatarsAction = setHideInviteAvatarsAction + ), eventSink = eventSink ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index 18ba93b639..f5c1386658 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -7,7 +7,9 @@ package io.element.android.features.preferences.impl.advanced +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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.ListSupportingTextDefaults 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.ui.strings.CommonStrings import io.element.android.services.analytics.compose.LocalAnalyticsService @@ -40,10 +46,21 @@ fun AdvancedSettingsView( modifier: Modifier = Modifier, ) { val analyticsService = LocalAnalyticsService.current + + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) + PreferencePage( modifier = modifier, onBackClick = onBackClick, - title = stringResource(id = CommonStrings.common_advanced_settings) + title = stringResource(id = CommonStrings.common_advanced_settings), + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } ) { PreferenceDropdown( title = stringResource(id = CommonStrings.common_appearance), @@ -115,10 +132,11 @@ private fun ModerationAndSafety( ) { PreferenceSwitch( title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), - isChecked = state.hideInviteAvatars, + isChecked = state.mediaPreviewConfigState.hideInviteAvatars, onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it)) }, + enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading() ) ListSectionHeader( title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), @@ -132,24 +150,36 @@ private fun ModerationAndSafety( ) ListItem( headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, - leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Off, compact = true), + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off, + compact = true + ), onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() ) ListItem( headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, - leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Private, compact = true), + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private, + compact = true + ), onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() ) ListItem( headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, - leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.On, compact = true), + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On, + compact = true + ), onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt new file mode 100644 index 0000000000..49f199ec3f --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt @@ -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, + val setTimelineMediaPreviewAction: AsyncAction, +) + +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.Uninitialized) + private val setTimelineMediaPreviewAction = mutableStateOf>(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)) + } + } + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index 317cd5b06c..de84e887d6 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -11,10 +11,12 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -34,6 +36,10 @@ class AdvancedSettingsPresenterTest { assertThat(isSharePresenceEnabled).isTrue() assertThat(doesCompressMedia).isTrue() assertThat(theme).isEqualTo(ThemeOption.System) + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) } } } @@ -124,49 +130,92 @@ class AdvancedSettingsPresenterTest { @Test fun `present - hide invite avatars`() = runTest { - val presenter = createAdvancedSettingsPresenter() + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { with(awaitItem()) { - assertThat(hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true)) } with(awaitItem()) { - assertThat(hideInviteAvatars).isTrue() + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(false)) } with(awaitItem()) { - assertThat(hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + } + } + assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false)) + } + + @Test + fun `present - timeline media preview value`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo( + listOf(MediaPreviewValue.Off, MediaPreviewValue.Private) + ) + } + + @Test + fun `present - media preview state with custom initial values`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + hideInviteAvatarsValue = true, + timelineMediaPreviewValue = MediaPreviewValue.Private + ) + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) } } } @Test - fun `present - timeline media preview value`() = runTest { - val presenter = createAdvancedSettingsPresenter() + fun `present - async actions state`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + setHideInviteAvatarsActionValue = AsyncAction.Loading, + setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit) + ) + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { with(awaitItem()) { - assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) - eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) - } - with(awaitItem()) { - assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) - eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) - } - with(awaitItem()) { - assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit)) } } } - private fun createAdvancedSettingsPresenter( + private fun CoroutineScope.createAdvancedSettingsPresenter( appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), ) = AdvancedSettingsPresenter( appPreferencesStore = appPreferencesStore, sessionPreferencesStore = sessionPreferencesStore, + mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, + sessionCoroutineScope = this, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt index 2844c2d7c1..d5e90047c7 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt @@ -15,6 +15,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService @@ -131,7 +132,7 @@ class AdvancedSettingsViewTest { } @Test - @Config(qualifiers = "h640dp") + @Config(qualifiers = "h1080dp") fun `clicking on hide invite avatars emits the expected event`() { val eventsRecorder = EventsRecorder() rule.setAdvancedSettingsView( @@ -145,8 +146,8 @@ class AdvancedSettingsViewTest { } @Test - @Config(qualifiers = "h1024dp") - fun `clicking on timeline media preview emits the expected event`() { + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview always hide emits the expected event`() { val eventsRecorder = EventsRecorder() rule.setAdvancedSettingsView( state = aAdvancedSettingsState( @@ -157,6 +158,65 @@ class AdvancedSettingsViewTest { rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview private rooms emits the expected event`() { + val eventsRecorder = EventsRecorder() + 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() + 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(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(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 AndroidComposeTestRule.setAdvancedSettingsView( diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt new file mode 100644 index 0000000000..18c6283965 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt @@ -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 = AsyncAction.Uninitialized, + setTimelineMediaPreviewActionValue: AsyncAction = 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() + private val setTimelineMediaPreviewValueEvents = mutableListOf() + + @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 = setHideInviteAvatarsEvents.toList() + fun getSetTimelineMediaPreviewValueEvents(): List = setTimelineMediaPreviewValueEvents.toList() +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt new file mode 100644 index 0000000000..01ede60e92 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt @@ -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> { 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> { + 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> { 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> { + 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 + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index f7d95d7078..d968ae63de 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -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.encryption.EncryptionService 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.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.oidc.AccountManagementAction @@ -72,6 +73,7 @@ interface MatrixClient { fun notificationSettingsService(): NotificationSettingsService fun encryptionService(): EncryptionService fun roomDirectoryService(): RoomDirectoryService + fun mediaPreviewService(): MediaPreviewService suspend fun getCacheSize(): Long /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewConfig.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewConfig.kt new file mode 100644 index 0000000000..66a53b3ad3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewConfig.kt @@ -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 + ) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt new file mode 100644 index 0000000000..dffa2d25e4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt @@ -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 + + /** + * Will emit the media preview config known by the client. + * This will emit a new value when received from sync. + */ + val mediaPreviewConfigFlow: StateFlow + + /** + * 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 + + /** + * 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 +} + +fun MediaPreviewService.getMediaPreviewValue() = mediaPreviewConfigFlow.value.mediaPreviewValue diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt index 83d6d464d5..25d489a48c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt @@ -29,6 +29,7 @@ fun MediaPreviewValue.isPreviewEnabled(joinRule: JoinRule?): Boolean { On -> true Off -> false Private -> when (joinRule) { + is JoinRule.Private, is JoinRule.Knock, is JoinRule.Invite, is JoinRule.Restricted, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index b14d7adb22..fca6439c05 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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.encryption.EncryptionService 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.notificationsettings.NotificationSettingsService 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.exception.mapClientException 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.notificationsettings.RustNotificationSettingsService import io.element.android.libraries.matrix.impl.oidc.toRustAction @@ -214,6 +216,12 @@ class RustMatrixClient( innerClient = innerClient, ) + private val mediaPreviewService = RustMediaPreviewService( + sessionCoroutineScope = sessionCoroutineScope, + innerClient = innerClient, + sessionDispatcher = sessionDispatcher, + ) + private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) private val _userProfile: MutableStateFlow = MutableStateFlow( @@ -507,6 +515,8 @@ class RustMatrixClient( override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService + override fun mediaPreviewService(): MediaPreviewService = mediaPreviewService + internal suspend fun destroy() { innerNotificationClient.close() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index 361d1efd97..6a04079a7e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -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.encryption.EncryptionService 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.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService @@ -71,4 +72,9 @@ object SessionMatrixModule { fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService { return matrixClient.roomDirectoryService() } + + @Provides + fun providesMediaPreviewService(matrixClient: MatrixClient): MediaPreviewService { + return matrixClient.mediaPreviewService() + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaPreviewService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaPreviewService.kt new file mode 100644 index 0000000000..e4e9bc07bd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaPreviewService.kt @@ -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 = + innerClient + .getMediaPreviewConfigFlow() + .stateIn(sessionCoroutineScope, started = SharingStarted.Lazily, initialValue = MediaPreviewConfig.DEFAULT) + + override suspend fun fetchMediaPreviewConfig(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.fetchMediaPreviewConfig()?.into() + } + } + + override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into()) + } + } + + override suspend fun setHideInviteAvatars(hide: Boolean): Result = 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 + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index c76d54a85c..9b609b39c7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -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.encryption.EncryptionService 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.notificationsettings.NotificationSettingsService 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.test.encryption.FakeEncryptionService 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.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.pushers.FakePushersService @@ -72,6 +74,7 @@ class FakeMatrixClient( private val syncService: FakeSyncService = FakeSyncService(), private val encryptionService: FakeEncryptionService = FakeEncryptionService(), private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(), + private val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, private val resolveRoomAliasResult: (RoomAlias) -> Result> = { Result.success( @@ -234,6 +237,7 @@ class FakeMatrixClient( override fun notificationService(): NotificationService = notificationService override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService override fun encryptionService(): EncryptionService = encryptionService + override fun mediaPreviewService(): MediaPreviewService = mediaPreviewService override fun roomMembershipObserver(): RoomMembershipObserver { return RoomMembershipObserver() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaPreviewService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaPreviewService.kt new file mode 100644 index 0000000000..d8e9114817 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaPreviewService.kt @@ -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 = MutableStateFlow(MediaPreviewConfig.DEFAULT), + private val fetchMediaPreviewConfigResult: () -> Result = { lambdaError() }, + private val setMediaPreviewValueResult: (MediaPreviewValue) -> Result = { lambdaError() }, + private val setHideInviteAvatarsResult: (Boolean) -> Result = { lambdaError() }, +) : MediaPreviewService { + override suspend fun fetchMediaPreviewConfig(): Result = simulateLongTask { + fetchMediaPreviewConfigResult() + } + + override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result = simulateLongTask { + setMediaPreviewValueResult(mediaPreviewValue) + } + + override suspend fun setHideInviteAvatars(hide: Boolean): Result = simulateLongTask { + setHideInviteAvatarsResult(hide) + } +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index dfc0e38b89..7e47785088 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -22,11 +22,14 @@ interface AppPreferencesStore { suspend fun setTheme(theme: String) fun getThemeFlow(): Flow - suspend fun setHideInviteAvatars(value: Boolean) - fun getHideInviteAvatarsFlow(): Flow - - suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) - fun getTimelineMediaPreviewValueFlow(): Flow + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + suspend fun setHideInviteAvatars(hide: Boolean?) + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + fun getHideInviteAvatarsFlow(): Flow + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + fun getTimelineMediaPreviewValueFlow(): Flow suspend fun setTracingLogLevel(logLevel: LogLevel) fun getTracingLogLevelFlow(): Flow diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index 599c73fc9d..0dff16ff98 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -85,27 +85,39 @@ class DefaultAppPreferencesStore @Inject constructor( } } - override suspend fun setHideInviteAvatars(value: Boolean) { - store.edit { prefs -> - prefs[hideInviteAvatarsKey] = value - } - } - - override fun getHideInviteAvatarsFlow(): Flow { + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override fun getHideInviteAvatarsFlow(): Flow { 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 -> - prefs[timelineMediaPreviewValueKey] = value.name + if (hide != null) { + prefs[hideInviteAvatarsKey] = hide + } else { + prefs.remove(hideInviteAvatarsKey) + } } } - override fun getTimelineMediaPreviewValueFlow(): Flow { + @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 { return store.data.map { prefs -> - prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } ?: MediaPreviewValue.On + prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } } } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index c5440a6be9..d0ee298a66 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -17,8 +17,8 @@ import kotlinx.coroutines.flow.MutableStateFlow class InMemoryAppPreferencesStore( isDeveloperModeEnabled: Boolean = false, customElementCallBaseUrl: String? = null, - hideInviteAvatars: Boolean = false, - timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, + hideInviteAvatars: Boolean? = null, + timelineMediaPreviewValue: MediaPreviewValue? = null, theme: String? = null, logLevel: LogLevel = LogLevel.INFO, traceLockPacks: Set = emptySet(), @@ -55,20 +55,24 @@ class InMemoryAppPreferencesStore( return theme } - override suspend fun setHideInviteAvatars(value: Boolean) { - hideInviteAvatars.value = value - } - - override fun getHideInviteAvatarsFlow(): Flow { + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override fun getHideInviteAvatarsFlow(): Flow { return hideInviteAvatars } - override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { - timelineMediaPreviewValue.value = value + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override fun getTimelineMediaPreviewValueFlow(): Flow { + return timelineMediaPreviewValue } - override fun getTimelineMediaPreviewValueFlow(): Flow { - return timelineMediaPreviewValue + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + 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) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index 9a6b119a26..7e7dbe3fb4 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.media.getMediaPreviewValue import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.permalink.PermalinkParser @@ -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.VoiceMessageType 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.notifications.model.FallbackNotifiableEvent 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.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.systemclock.SystemClock -import kotlinx.coroutines.flow.first import timber.log.Timber import javax.inject.Inject @@ -79,7 +78,6 @@ class DefaultNotifiableEventResolver @Inject constructor( @ApplicationContext private val context: Context, private val permalinkParser: PermalinkParser, private val callNotificationEventResolver: CallNotificationEventResolver, - private val appPreferencesStore: AppPreferencesStore, ) : NotifiableEventResolver { override suspend fun resolveEvents( sessionId: SessionId, @@ -117,6 +115,7 @@ class DefaultNotifiableEventResolver @Inject constructor( ): Result = runCatchingExceptions { when (val content = this.content) { is NotificationContent.MessageLike.RoomMessage -> { + val showMediaPreview = client.mediaPreviewService().getMediaPreviewValue() == MediaPreviewValue.On val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId) val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName) val notifiableMessageEvent = buildNotifiableMessageEvent( @@ -129,8 +128,8 @@ class DefaultNotifiableEventResolver @Inject constructor( timestamp = this.timestamp, senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, body = messageBody, - imageUriString = content.fetchImageIfPresent(client)?.toString(), - imageMimeType = content.getImageMimetype(), + imageUriString = if (showMediaPreview) content.fetchImageIfPresent(client)?.toString() else null, + imageMimeType = if (showMediaPreview) content.getImageMimetype() else null, roomName = roomDisplayName, roomIsDm = isDm, roomAvatarPath = roomAvatarUrl, @@ -323,9 +322,6 @@ class DefaultNotifiableEventResolver @Inject constructor( } private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? { - if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) { - return null - } val fileResult = when (val messageType = messageType) { is ImageMessageType -> notificationMediaRepoFactory.create(client) .getMediaFile( @@ -349,10 +345,7 @@ class DefaultNotifiableEventResolver @Inject constructor( .getOrNull() } - private suspend fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? { - if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) { - return null - } + private fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? { return when (val messageType = messageType) { is ImageMessageType -> messageType.info?.mimetype is VideoMessageType -> null // Use the thumbnail here? diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index 5b93e90064..3c609f184e 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -43,8 +43,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notification.aNotificationData import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent @@ -826,7 +824,6 @@ class DefaultNotifiableEventResolverTest { private fun createDefaultNotifiableEventResolver( notificationService: FakeNotificationService? = FakeNotificationService(), notificationResult: Result> = Result.success(emptyMap()), - appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(), ): DefaultNotifiableEventResolver { val context = RuntimeEnvironment.getApplication() as Context @@ -849,7 +846,6 @@ class DefaultNotifiableEventResolverTest { context = context, permalinkParser = FakePermalinkParser(), callNotificationEventResolver = callNotificationEventResolver, - appPreferencesStore = appPreferencesStore, ) } } diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png new file mode 100644 index 0000000000..38100458c3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e639d6e0f93e15f3ea39a0e0d9561a8da485abcc97a92b7370d33f2b701c40bb +size 46602 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png new file mode 100644 index 0000000000..310b4fc6cb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:264e12f555c641960f0fef0c1177715fa5d596f7a42e0796d49533b68cfc88b9 +size 46256 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png new file mode 100644 index 0000000000..3d55d29e23 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b797e153358ac8eb1e226ad537c23c61f4dd97ab8d5947b98bdd9b193f369b7 +size 48487 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en.png new file mode 100644 index 0000000000..da0c043028 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3853ed9a82dfa5bdf5f0e4d79e7af04bc7e43531e691c4d5fb17a37f5bd4f5b4 +size 48249