From 682897cdb6a9a84ca05633633ae2ccf383889b79 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 25 Jun 2025 20:38:39 +0200 Subject: [PATCH 01/10] change (media preview config) : introduce new apis from sdk --- .../libraries/matrix/api/MatrixClient.kt | 7 ++ .../matrix/api/media/MediaPreviewConfig.kt | 16 ++++ .../matrix/api/media/MediaPreviewService.kt | 42 ++++++++++ .../libraries/matrix/impl/RustMatrixClient.kt | 14 ++++ .../matrix/impl/di/SessionMatrixModule.kt | 6 ++ .../impl/media/RustMediaPreviewService.kt | 84 +++++++++++++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 8 ++ .../test/media/FakeMediaPreviewService.kt | 50 +++++++++++ 8 files changed, 227 insertions(+) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewConfig.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaPreviewService.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaPreviewService.kt 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..81aa28be6b 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 @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.api import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.ProgressCallback @@ -19,6 +20,9 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.oidc.AccountManagementAction @@ -40,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext import java.util.Optional interface MatrixClient { @@ -72,6 +77,7 @@ interface MatrixClient { fun notificationSettingsService(): NotificationSettingsService fun encryptionService(): EncryptionService fun roomDirectoryService(): RoomDirectoryService + fun mediaPreviewService(): MediaPreviewService suspend fun getCacheSize(): Long /** @@ -169,6 +175,7 @@ interface MatrixClient { * Return true if Livekit Rtc is supported, i.e. if Element Call is available. */ suspend fun isLivekitRtcSupported(): Boolean + } /** 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..5c964ca15e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewConfig.kt @@ -0,0 +1,16 @@ +/* + * 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, +) 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..19cbe9cf2a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt @@ -0,0 +1,42 @@ +/* + * 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.Flow + +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. + */ + fun getMediaPreviewConfigFlow(): Flow + + /** + * Get the media preview display policy from the cache. This value is updated through sync. + */ + suspend fun getMediaPreviewValue(): MediaPreviewValue? + + /** + * Get the invite avatars display policy from the cache. This value is updated through sync. + */ + suspend fun getHideInviteAvatars(): Boolean + + /** + * Set the media preview display policy. This will update the value on the server and update the local value when successful. + */ + 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 +} 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 07b8b3de09..1e9d703b28 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,9 @@ import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.oidc.AccountManagementAction @@ -52,6 +55,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 @@ -107,6 +111,7 @@ import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientException import org.matrix.rustcomponents.sdk.IgnoredUsersListener +import org.matrix.rustcomponents.sdk.InviteAvatars import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.PowerLevels import org.matrix.rustcomponents.sdk.RoomInfoListener @@ -214,6 +219,11 @@ class RustMatrixClient( innerClient = innerClient, ) + private val mediaPreviewService = RustMediaPreviewService( + innerClient = innerClient, + sessionDispatcher = sessionDispatcher, + ) + private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) private val _userProfile: MutableStateFlow = MutableStateFlow( @@ -507,6 +517,8 @@ class RustMatrixClient( override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService + override fun mediaPreviewService(): MediaPreviewService = mediaPreviewService + internal suspend fun destroy() { innerNotificationClient.close() @@ -682,6 +694,8 @@ class RustMatrixClient( innerClient.isLivekitRtcSupported() } + + private suspend fun File.getCacheSize( includeCryptoDb: Boolean = false, ): Long = withContext(sessionDispatcher) { 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..a2ab6c8bb9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaPreviewService.kt @@ -0,0 +1,84 @@ +/* + * 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.flow.Flow +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( + private val sessionDispatcher: CoroutineDispatcher, + private val innerClient: Client, +) : MediaPreviewService { + override suspend fun fetchMediaPreviewConfig(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.fetchMediaPreviewConfig()?.into() + } + } + + override fun getMediaPreviewConfigFlow(): Flow = innerClient.getMediaPreviewConfigFlow() + + override suspend fun getMediaPreviewValue(): MediaPreviewValue? = innerClient.getMediaPreviewDisplayPolicy()?.into() + + override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into()) + } + } + + override suspend fun getHideInviteAvatars(): Boolean = innerClient.getInviteAvatarsDisplayPolicy() == InviteAvatars.OFF + + 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 = this@into.mediaPreviews.into(), + hideInviteAvatar = inviteAvatars == InviteAvatars.OFF + ) +} + +private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow { + subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener { + override fun onChange(mediaPreviewConfig: RustMediaPreviewConfig?) { + 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..11b95a647e 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 @@ -17,7 +17,10 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.oidc.AccountManagementAction @@ -36,6 +39,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 +76,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( @@ -91,6 +96,7 @@ class FakeMatrixClient( private val canReportRoomLambda: () -> Boolean = { false }, private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, override val ignoredUsersFlow: StateFlow> = MutableStateFlow(persistentListOf()), + ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -234,11 +240,13 @@ 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() } + // Mocks fun givenCreateRoomResult(result: Result) { 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..7e41b03e41 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaPreviewService.kt @@ -0,0 +1,50 @@ +/* + * 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.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeMediaPreviewService( + private val fetchMediaPreviewConfigResult: () -> Result = { lambdaError() }, + private val mediaPreviewConfigFlow: Flow = flowOf(null), + private val getMediaPreviewValue: ()-> MediaPreviewValue? = { null }, + private val getHideInviteAvatars: () -> Boolean = { false }, + private val setMediaPreviewValueResult: (MediaPreviewValue) -> Result = { lambdaError() }, + private val setHideInviteAvatarsResult: (Boolean) -> Result = { lambdaError() }, +): MediaPreviewService { + + override suspend fun fetchMediaPreviewConfig(): Result = simulateLongTask { + fetchMediaPreviewConfigResult() + } + + override fun getMediaPreviewConfigFlow(): Flow { + return mediaPreviewConfigFlow + } + + override suspend fun getMediaPreviewValue(): MediaPreviewValue? = simulateLongTask { + getMediaPreviewValue.invoke() + } + + override suspend fun getHideInviteAvatars(): Boolean = simulateLongTask { + getHideInviteAvatars.invoke() + } + + override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result = simulateLongTask { + setMediaPreviewValueResult(mediaPreviewValue) + } + + override suspend fun setHideInviteAvatars(hide: Boolean): Result = simulateLongTask { + setHideInviteAvatarsResult(hide) + } +} From 0b748aa8cbad7bcb955027a3ee3dc92cc136d2ac Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Jun 2025 20:52:44 +0200 Subject: [PATCH 02/10] change (media preview config) : use the new apis --- .../advanced/AdvancedSettingsPresenter.kt | 44 +++---- .../impl/advanced/AdvancedSettingsState.kt | 3 + .../advanced/AdvancedSettingsStateProvider.kt | 9 +- .../advanced/MediaPreviewConfigStateStore.kt | 109 ++++++++++++++++++ .../advanced/AdvancedSettingsPresenterTest.kt | 13 ++- .../api/store/AppPreferencesStore.kt | 2 - .../impl/store/DefaultAppPreferencesStore.kt | 12 -- .../test/InMemoryAppPreferencesStore.kt | 8 -- .../DefaultNotifiableEventResolver.kt | 16 +-- .../DefaultNotifiableEventResolverTest.kt | 2 - 10 files changed, 158 insertions(+), 60 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt 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..a47ea25e4e 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 @@ -8,27 +8,36 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.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.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.CoroutineScope 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 +50,6 @@ 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 themeOption by remember { derivedStateOf { @@ -61,28 +63,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 +89,11 @@ class AdvancedSettingsPresenter @Inject constructor( isSharePresenceEnabled = isSharePresenceEnabled, doesCompressMedia = doesCompressMedia, theme = themeOption, - hideInviteAvatars = hideInviteAvatars, - timelineMediaPreviewValue = timelineMediaPreviewValue, - eventSink = { handleEvents(it) } + hideInviteAvatars = mediaPreviewConfigStateStore.hideInviteAvatars.value, + timelineMediaPreviewValue = mediaPreviewConfigStateStore.timelineMediaPreviewValue.value, + setHideInviteAvatarsAction = mediaPreviewConfigStateStore.setHideInviteAvatarsAction.value, + setTimelineMediaPreviewAction = mediaPreviewConfigStateStore.setTimelineMediaPreviewAction.value, + 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..6386984146 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 @@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.res.stringResource +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.preferences.DropdownOption import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.ui.strings.CommonStrings @@ -21,6 +22,8 @@ data class AdvancedSettingsState( val theme: ThemeOption, val hideInviteAvatars: Boolean, val timelineMediaPreviewValue: MediaPreviewValue, + val setHideInviteAvatarsAction: AsyncAction, + val setTimelineMediaPreviewAction: AsyncAction, 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..09b3b6e1a5 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, @@ -37,5 +42,7 @@ fun aAdvancedSettingsState( theme = theme, 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/MediaPreviewConfigStateStore.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt new file mode 100644 index 0000000000..c503ba49a2 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt @@ -0,0 +1,109 @@ +/* + * 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.State +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.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +interface MediaPreviewConfigStateStore { + val hideInviteAvatars: State + val timelineMediaPreviewValue: State + val setHideInviteAvatarsAction: State> + val setTimelineMediaPreviewAction: State> + + fun setHideInviteAvatars(hide: Boolean) + fun setTimelineMediaPreviewValue(value: MediaPreviewValue) +} + +@ContributesBinding(SessionScope::class, boundType = MediaPreviewConfigStateStore::class) +@SingleIn(SessionScope::class) +class DefaultMediaPreviewConfigStateStore @Inject constructor( + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val mediaPreviewService: MediaPreviewService, + private val snackbarDispatcher: SnackbarDispatcher, +) : MediaPreviewConfigStateStore { + override val hideInviteAvatars = mutableStateOf(false) + override val timelineMediaPreviewValue = mutableStateOf(MediaPreviewValue.On) + override val setHideInviteAvatarsAction = mutableStateOf>(AsyncAction.Uninitialized) + override val setTimelineMediaPreviewAction = mutableStateOf>(AsyncAction.Uninitialized) + + init { + val configFlow = mediaPreviewService.getMediaPreviewConfigFlow().shareIn(sessionCoroutineScope, SharingStarted.Eagerly) + val hideInviteAvatarsFlow = configFlow.mapNotNull { it?.hideInviteAvatar }.distinctUntilChanged() + val timelineMediaPreviewFlow = configFlow.mapNotNull { it?.mediaPreviewValue }.distinctUntilChanged() + + hideInviteAvatarsFlow + .onEach { + Timber.d("Hide invi@te 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) + } + + override fun setHideInviteAvatars(hide: Boolean) { + sessionCoroutineScope.launch { + Timber.d("Setting hide invite avatars to $hide") + val prevHideInviteAvatars = hideInviteAvatars.value + 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 { + Timber.d("Setting timeline media preview value to $value") + val prevTimelineMediaPreviewValue = timelineMediaPreviewValue.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..de841f3f6d 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,14 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -144,7 +148,12 @@ class AdvancedSettingsPresenterTest { @Test fun `present - timeline media preview value`() = runTest { - val presenter = createAdvancedSettingsPresenter() + val mediaPreviewConfigFlow = MutableStateFlow(null) + val presenter = createAdvancedSettingsPresenter( + matrixClient = FakeMatrixClient( + mediaPreviewConfigFlow = mediaPreviewConfigFlow + ) + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -165,8 +174,10 @@ class AdvancedSettingsPresenterTest { private fun createAdvancedSettingsPresenter( appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + matrixClient: MatrixClient = FakeMatrixClient(), ) = AdvancedSettingsPresenter( appPreferencesStore = appPreferencesStore, sessionPreferencesStore = sessionPreferencesStore, + matrixClient = matrixClient, ) } 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..2434823b58 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,10 +22,8 @@ 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 suspend fun setTracingLogLevel(logLevel: LogLevel) 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..abdd481f67 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,24 +85,12 @@ class DefaultAppPreferencesStore @Inject constructor( } } - override suspend fun setHideInviteAvatars(value: Boolean) { - store.edit { prefs -> - prefs[hideInviteAvatarsKey] = value - } - } - override fun getHideInviteAvatarsFlow(): Flow { return store.data.map { prefs -> prefs[hideInviteAvatarsKey] == true } } - override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { - store.edit { prefs -> - prefs[timelineMediaPreviewValueKey] = value.name - } - } - override fun getTimelineMediaPreviewValueFlow(): Flow { return store.data.map { prefs -> prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } ?: MediaPreviewValue.On 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..33b69dc8ac 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 @@ -55,18 +55,10 @@ class InMemoryAppPreferencesStore( return theme } - override suspend fun setHideInviteAvatars(value: Boolean) { - hideInviteAvatars.value = value - } - override fun getHideInviteAvatarsFlow(): Flow { return hideInviteAvatars } - override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { - timelineMediaPreviewValue.value = value - } - override fun getTimelineMediaPreviewValueFlow(): Flow { return timelineMediaPreviewValue } 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..dd2f7eb760 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 @@ -41,7 +41,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 +49,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 +77,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 +114,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 +127,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 +321,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 +344,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..cb3c0a78f0 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 @@ -826,7 +826,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 +848,6 @@ class DefaultNotifiableEventResolverTest { context = context, permalinkParser = FakePermalinkParser(), callNotificationEventResolver = callNotificationEventResolver, - appPreferencesStore = appPreferencesStore, ) } } From 42af43ea7a0e47089332a341700a40c4f955e521 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Jun 2025 20:53:00 +0200 Subject: [PATCH 03/10] change (media preview config) : handle loading and failure ui --- .../impl/advanced/AdvancedSettingsView.kt | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) 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..924f178b32 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,10 @@ package io.element.android.features.preferences.impl.advanced +import androidx.compose.foundation.layout.Row +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 +30,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 +47,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), @@ -119,15 +137,18 @@ private fun ModerationAndSafety( onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it)) }, + enabled = !state.setHideInviteAvatarsAction.isLoading() ) ListSectionHeader( title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), hasDivider = false, description = { - ListSupportingText( - text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), - contentPadding = ListSupportingTextDefaults.Padding.None, - ) + Row { + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + } } ) ListItem( @@ -136,6 +157,7 @@ private fun ModerationAndSafety( onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) }, + enabled = !state.setTimelineMediaPreviewAction.isLoading() ) ListItem( headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, @@ -143,6 +165,7 @@ private fun ModerationAndSafety( onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) }, + enabled = !state.setTimelineMediaPreviewAction.isLoading() ) ListItem( headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, @@ -150,6 +173,7 @@ private fun ModerationAndSafety( onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) }, + enabled = !state.setTimelineMediaPreviewAction.isLoading() ) } } From 7fb0b36a9edd4f54aaf3114d64e89cdee35181f0 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Jun 2025 21:44:03 +0200 Subject: [PATCH 04/10] change (media preview config) : manage migration of local data --- .../android/appnav/LoggedInFlowNode.kt | 3 + .../loggedin/MediaPreviewConfigMigration.kt | 56 +++++++++++++++++++ .../api/store/AppPreferencesStore.kt | 7 ++- .../impl/store/DefaultAppPreferencesStore.kt | 28 ++++++++-- .../test/InMemoryAppPreferencesStore.kt | 16 ++++-- 5 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt 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 9a07c04f57..12b61b2ee6 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 @@ -114,6 +115,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( @@ -179,6 +181,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..ac7a1c6145 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt @@ -0,0 +1,56 @@ +/* + * 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, +) { + 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.d("Couldn't perform migration, failed to fetch media preview config.") + } + } +} 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 2434823b58..59a074c99b 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,9 +22,10 @@ interface AppPreferencesStore { suspend fun setTheme(theme: String) fun getThemeFlow(): Flow - fun getHideInviteAvatarsFlow(): Flow - - fun getTimelineMediaPreviewValueFlow(): Flow + suspend fun setHideInviteAvatars(hide: Boolean?) + fun getHideInviteAvatarsFlow(): Flow + suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) + 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 abdd481f67..1ea7c1f874 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,15 +85,35 @@ class DefaultAppPreferencesStore @Inject constructor( } } - override fun getHideInviteAvatarsFlow(): Flow { + override fun getHideInviteAvatarsFlow(): Flow { return store.data.map { prefs -> - prefs[hideInviteAvatarsKey] == true + prefs[hideInviteAvatarsKey] } } - override fun getTimelineMediaPreviewValueFlow(): Flow { + override suspend fun setHideInviteAvatars(hide: Boolean?) { + store.edit { prefs -> + if (hide != null) { + prefs[hideInviteAvatarsKey] = hide + } else { + prefs.remove(hideInviteAvatarsKey) + } + } + } + + override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) { + store.edit { prefs -> + if (mediaPreviewValue != null) { + prefs[timelineMediaPreviewValueKey] = mediaPreviewValue.name + } else { + prefs.remove(timelineMediaPreviewValueKey) + } + } + } + + 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 33b69dc8ac..b474ec63ea 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,14 +55,22 @@ class InMemoryAppPreferencesStore( return theme } - override fun getHideInviteAvatarsFlow(): Flow { + override fun getHideInviteAvatarsFlow(): Flow { return hideInviteAvatars } - override fun getTimelineMediaPreviewValueFlow(): Flow { + override fun getTimelineMediaPreviewValueFlow(): Flow { return timelineMediaPreviewValue } + override suspend fun setHideInviteAvatars(hide: Boolean?) { + hideInviteAvatars.value = hide + } + + override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) { + timelineMediaPreviewValue.value = mediaPreviewValue + } + override suspend fun setTracingLogLevel(logLevel: LogLevel) { this.logLevel.value = logLevel } From cbc10ad50e2a1c1166a8a6208c740cbb07165d40 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 30 Jun 2025 21:24:01 +0200 Subject: [PATCH 05/10] change (media preview config) : fix JoinRule.Private case --- .../android/libraries/matrix/api/media/MediaPreviewValue.kt | 1 + 1 file changed, 1 insertion(+) 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, From 4734b560f76f9dc9910836739678fead048e6f01 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 30 Jun 2025 21:31:58 +0200 Subject: [PATCH 06/10] change (media preview config) : final refactoring and tests --- .../loggedin/MediaPreviewConfigMigration.kt | 4 +- .../MediaPreviewConfigMigrationTest.kt | 160 ++++++++++++++ .../joinroom/impl/JoinRoomPresenter.kt | 10 +- .../joinroom/impl/di/JoinRoomModule.kt | 3 - .../joinroom/impl/JoinRoomPresenterTest.kt | 4 - .../protection/TimelineProtectionPresenter.kt | 10 +- .../TimelineProtectionPresenterTest.kt | 27 ++- .../advanced/AdvancedSettingsPresenter.kt | 13 +- .../impl/advanced/AdvancedSettingsState.kt | 7 +- .../advanced/AdvancedSettingsStateProvider.kt | 12 +- .../impl/advanced/AdvancedSettingsView.kt | 25 ++- .../advanced/MediaPreviewConfigStateStore.kt | 49 +++-- .../advanced/AdvancedSettingsPresenterTest.kt | 90 +++++--- .../impl/advanced/AdvancedSettingsViewTest.kt | 66 +++++- .../FakeMediaPreviewConfigStateStore.kt | 51 +++++ .../MediaPreviewConfigStateStoreTest.kt | 206 ++++++++++++++++++ .../roomlist/impl/RoomListPresenter.kt | 8 +- .../libraries/matrix/api/MatrixClient.kt | 5 - .../matrix/api/media/MediaPreviewConfig.kt | 12 +- .../matrix/api/media/MediaPreviewService.kt | 17 +- .../libraries/matrix/impl/RustMatrixClient.kt | 6 +- .../impl/media/RustMediaPreviewService.kt | 22 +- .../libraries/matrix/test/FakeMatrixClient.kt | 4 - .../test/media/FakeMediaPreviewService.kt | 23 +- .../api/store/AppPreferencesStore.kt | 4 + .../DefaultNotifiableEventResolver.kt | 1 + .../DefaultNotifiableEventResolverTest.kt | 2 - 27 files changed, 676 insertions(+), 165 deletions(-) create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt 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 index ac7a1c6145..54299acc74 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt @@ -26,6 +26,7 @@ class MediaPreviewConfigMigration @Inject constructor( @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) { + @Suppress("DEPRECATION") operator fun invoke() = sessionCoroutineScope.launch { val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first() val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first() @@ -49,7 +50,8 @@ class MediaPreviewConfigMigration @Inject constructor( appPreferencesStore.setTimelineMediaPreviewValue(null) } } - }.onFailure { + } + .onFailure { Timber.d("Couldn't perform migration, failed to fetch media preview config.") } } 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/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 a47ea25e4e..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 @@ -8,21 +8,15 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme -import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import kotlinx.coroutines.CoroutineScope @@ -51,6 +45,8 @@ class AdvancedSettingsPresenter @Inject constructor( appPreferencesStore.getThemeFlow().mapToTheme() }.collectAsState(initial = Theme.System) + val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() + val themeOption by remember { derivedStateOf { when (theme.value) { @@ -89,10 +85,7 @@ class AdvancedSettingsPresenter @Inject constructor( isSharePresenceEnabled = isSharePresenceEnabled, doesCompressMedia = doesCompressMedia, theme = themeOption, - hideInviteAvatars = mediaPreviewConfigStateStore.hideInviteAvatars.value, - timelineMediaPreviewValue = mediaPreviewConfigStateStore.timelineMediaPreviewValue.value, - setHideInviteAvatarsAction = mediaPreviewConfigStateStore.setHideInviteAvatarsAction.value, - setTimelineMediaPreviewAction = mediaPreviewConfigStateStore.setTimelineMediaPreviewAction.value, + mediaPreviewConfigState = mediaPreviewConfigState, eventSink = ::handleEvents, ) } 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 6386984146..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 @@ -10,9 +10,7 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.res.stringResource -import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.preferences.DropdownOption -import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.ui.strings.CommonStrings data class AdvancedSettingsState( @@ -20,10 +18,7 @@ data class AdvancedSettingsState( val isSharePresenceEnabled: Boolean, val doesCompressMedia: Boolean, val theme: ThemeOption, - val hideInviteAvatars: Boolean, - val timelineMediaPreviewValue: MediaPreviewValue, - val setHideInviteAvatarsAction: AsyncAction, - val setTimelineMediaPreviewAction: AsyncAction, + 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 09b3b6e1a5..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 @@ -29,8 +29,8 @@ fun aAdvancedSettingsState( isDeveloperModeEnabled: Boolean = false, isSharePresenceEnabled: Boolean = false, doesCompressMedia: Boolean = false, - hideInviteAvatars: Boolean = false, theme: ThemeOption = ThemeOption.System, + hideInviteAvatars: Boolean = false, timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, setTimelineMediaPreviewAction: AsyncAction = AsyncAction.Uninitialized, setHideInviteAvatarsAction: AsyncAction = AsyncAction.Uninitialized, @@ -40,9 +40,11 @@ fun aAdvancedSettingsState( isSharePresenceEnabled = isSharePresenceEnabled, doesCompressMedia = doesCompressMedia, theme = theme, - hideInviteAvatars = hideInviteAvatars, - timelineMediaPreviewValue = timelineMediaPreviewValue, - setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, - setHideInviteAvatarsAction = setHideInviteAvatarsAction, + mediaPreviewConfigState = MediaPreviewConfigState( + hideInviteAvatars = hideInviteAvatars, + timelineMediaPreviewValue = timelineMediaPreviewValue, + setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, + setHideInviteAvatarsAction = setHideInviteAvatarsAction + ), eventSink = eventSink ) 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 924f178b32..c92f50bd2e 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 @@ -133,11 +133,11 @@ private fun ModerationAndSafety( ) { PreferenceSwitch( title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), - isChecked = state.hideInviteAvatars, + isChecked = state.mediaPreviewConfigState.hideInviteAvatars, onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it)) }, - enabled = !state.setHideInviteAvatarsAction.isLoading() + enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading() ) ListSectionHeader( title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), @@ -153,27 +153,36 @@ private fun ModerationAndSafety( ) ListItem( headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, - leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Off, compact = true), + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off, + compact = true + ), onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) }, - enabled = !state.setTimelineMediaPreviewAction.isLoading() + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() ) ListItem( headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, - leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Private, compact = true), + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private, + compact = true + ), onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) }, - enabled = !state.setTimelineMediaPreviewAction.isLoading() + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() ) ListItem( headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, - leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.On, compact = true), + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On, + compact = true + ), onClick = { state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) }, - enabled = !state.setTimelineMediaPreviewAction.isLoading() + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() ) } } 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 index c503ba49a2..715938b93b 100644 --- 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 @@ -7,7 +7,7 @@ package io.element.android.features.preferences.impl.advanced -import androidx.compose.runtime.State +import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.architecture.AsyncAction @@ -21,27 +21,29 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -interface MediaPreviewConfigStateStore { - val hideInviteAvatars: State - val timelineMediaPreviewValue: State - val setHideInviteAvatarsAction: State> - val setTimelineMediaPreviewAction: State> +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, boundType = MediaPreviewConfigStateStore::class) +@ContributesBinding(SessionScope::class) @SingleIn(SessionScope::class) class DefaultMediaPreviewConfigStateStore @Inject constructor( @SessionCoroutineScope @@ -49,19 +51,19 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor( private val mediaPreviewService: MediaPreviewService, private val snackbarDispatcher: SnackbarDispatcher, ) : MediaPreviewConfigStateStore { - override val hideInviteAvatars = mutableStateOf(false) - override val timelineMediaPreviewValue = mutableStateOf(MediaPreviewValue.On) - override val setHideInviteAvatarsAction = mutableStateOf>(AsyncAction.Uninitialized) - override val setTimelineMediaPreviewAction = mutableStateOf>(AsyncAction.Uninitialized) + 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.getMediaPreviewConfigFlow().shareIn(sessionCoroutineScope, SharingStarted.Eagerly) - val hideInviteAvatarsFlow = configFlow.mapNotNull { it?.hideInviteAvatar }.distinctUntilChanged() - val timelineMediaPreviewFlow = configFlow.mapNotNull { it?.mediaPreviewValue }.distinctUntilChanged() + val configFlow = mediaPreviewService.mediaPreviewConfigFlow + val hideInviteAvatarsFlow = configFlow.map { it.hideInviteAvatar }.distinctUntilChanged() + val timelineMediaPreviewFlow = configFlow.map { it.mediaPreviewValue }.distinctUntilChanged() hideInviteAvatarsFlow .onEach { - Timber.d("Hide invi@te avatars changed to $it") + Timber.d("Hide invite avatars changed to $it") hideInviteAvatars.value = it } .launchIn(sessionCoroutineScope) @@ -74,6 +76,16 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor( .launchIn(sessionCoroutineScope) } + @Composable + override fun state(): MediaPreviewConfigState { + return MediaPreviewConfigState( + hideInviteAvatars = hideInviteAvatars.value, + timelineMediaPreviewValue = timelineMediaPreviewValue.value, + setHideInviteAvatarsAction = setHideInviteAvatarsAction.value, + setTimelineMediaPreviewAction = setTimelineMediaPreviewAction.value, + ) + } + override fun setHideInviteAvatars(hide: Boolean) { sessionCoroutineScope.launch { Timber.d("Setting hide invite avatars to $hide") @@ -106,4 +118,3 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor( } } } - 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 de841f3f6d..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,14 +11,12 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.media.MediaPreviewValue -import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.tests.testutils.WarmUpRule -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -38,6 +36,10 @@ class AdvancedSettingsPresenterTest { assertThat(isSharePresenceEnabled).isTrue() assertThat(doesCompressMedia).isTrue() assertThat(theme).isEqualTo(ThemeOption.System) + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) } } } @@ -128,56 +130,92 @@ class AdvancedSettingsPresenterTest { @Test fun `present - hide invite avatars`() = runTest { - val presenter = createAdvancedSettingsPresenter() + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { with(awaitItem()) { - assertThat(hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true)) } with(awaitItem()) { - assertThat(hideInviteAvatars).isTrue() + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(false)) } with(awaitItem()) { - assertThat(hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + } + } + assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false)) + } + + @Test + fun `present - timeline media preview value`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo( + listOf(MediaPreviewValue.Off, MediaPreviewValue.Private) + ) + } + + @Test + fun `present - media preview state with custom initial values`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + hideInviteAvatarsValue = true, + timelineMediaPreviewValue = MediaPreviewValue.Private + ) + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) } } } @Test - fun `present - timeline media preview value`() = runTest { - val mediaPreviewConfigFlow = MutableStateFlow(null) - val presenter = createAdvancedSettingsPresenter( - matrixClient = FakeMatrixClient( - mediaPreviewConfigFlow = mediaPreviewConfigFlow - ) + fun `present - async actions state`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + setHideInviteAvatarsActionValue = AsyncAction.Loading, + setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit) ) + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { with(awaitItem()) { - assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) - eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) - } - with(awaitItem()) { - assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) - eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) - } - with(awaitItem()) { - assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit)) } } } - private fun createAdvancedSettingsPresenter( + private fun CoroutineScope.createAdvancedSettingsPresenter( appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), - matrixClient: MatrixClient = FakeMatrixClient(), + mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), ) = AdvancedSettingsPresenter( appPreferencesStore = appPreferencesStore, sessionPreferencesStore = sessionPreferencesStore, - matrixClient = matrixClient, + mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, + sessionCoroutineScope = this, ) } 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 baf55d2999..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 = "h640dp") - fun `clicking on timeline media preview emits the expected event`() { + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview always hide emits the expected event`() { val eventsRecorder = EventsRecorder() 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/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 9e8199cd80..59ff6bc379 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -37,6 +37,7 @@ import io.element.android.features.roomlist.impl.search.RoomListSearchEvents import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -120,8 +121,11 @@ class RoomListPresenter @Inject constructor( // Avatar indicator val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val hideInvitesAvatar by remember { - appPreferencesStore.getHideInviteAvatarsFlow() - }.collectAsState(initial = false) + client + .mediaPreviewService() + .mediaPreviewConfigFlow + .mapState { config -> config.hideInviteAvatar } + }.collectAsState() val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) } 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 81aa28be6b..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 @@ -8,7 +8,6 @@ package io.element.android.libraries.matrix.api import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.ProgressCallback @@ -20,9 +19,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.media.MatrixMediaLoader -import io.element.android.libraries.matrix.api.media.MediaPreviewConfig import io.element.android.libraries.matrix.api.media.MediaPreviewService -import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.oidc.AccountManagementAction @@ -44,7 +41,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext import java.util.Optional interface MatrixClient { @@ -175,7 +171,6 @@ interface MatrixClient { * Return true if Livekit Rtc is supported, i.e. if Element Call is available. */ suspend fun isLivekitRtcSupported(): Boolean - } /** 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 index 5c964ca15e..66a53b3ad3 100644 --- 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 @@ -13,4 +13,14 @@ package io.element.android.libraries.matrix.api.media data class MediaPreviewConfig( val mediaPreviewValue: MediaPreviewValue, val hideInviteAvatar: Boolean, -) +) { + companion object { + /** + * The default config if unknown (no local nor server config). + */ + val DEFAULT = MediaPreviewConfig( + mediaPreviewValue = MediaPreviewValue.On, + hideInviteAvatar = false + ) + } +} 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 index 19cbe9cf2a..dffa2d25e4 100644 --- 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 @@ -7,7 +7,7 @@ package io.element.android.libraries.matrix.api.media -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow interface MediaPreviewService { /** @@ -19,24 +19,17 @@ interface MediaPreviewService { * Will emit the media preview config known by the client. * This will emit a new value when received from sync. */ - fun getMediaPreviewConfigFlow(): Flow - - /** - * Get the media preview display policy from the cache. This value is updated through sync. - */ - suspend fun getMediaPreviewValue(): MediaPreviewValue? - - /** - * Get the invite avatars display policy from the cache. This value is updated through sync. - */ - suspend fun getHideInviteAvatars(): Boolean + val mediaPreviewConfigFlow: StateFlow /** * 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/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 1e9d703b28..d71db05a02 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,9 +26,7 @@ import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.media.MatrixMediaLoader -import io.element.android.libraries.matrix.api.media.MediaPreviewConfig import io.element.android.libraries.matrix.api.media.MediaPreviewService -import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.oidc.AccountManagementAction @@ -111,7 +109,6 @@ import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientException import org.matrix.rustcomponents.sdk.IgnoredUsersListener -import org.matrix.rustcomponents.sdk.InviteAvatars import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.PowerLevels import org.matrix.rustcomponents.sdk.RoomInfoListener @@ -220,6 +217,7 @@ class RustMatrixClient( ) private val mediaPreviewService = RustMediaPreviewService( + sessionCoroutineScope = sessionCoroutineScope, innerClient = innerClient, sessionDispatcher = sessionDispatcher, ) @@ -694,8 +692,6 @@ class RustMatrixClient( innerClient.isLivekitRtcSupported() } - - private suspend fun File.getCacheSize( includeCryptoDb: Boolean = false, ): Long = withContext(sessionDispatcher) { 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 index a2ab6c8bb9..c18f85712f 100644 --- 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 @@ -13,7 +13,10 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.InviteAvatars @@ -22,27 +25,27 @@ import org.matrix.rustcomponents.sdk.MediaPreviews import org.matrix.rustcomponents.sdk.MediaPreviewConfig as RustMediaPreviewConfig class RustMediaPreviewService( + sessionCoroutineScope: CoroutineScope, private val sessionDispatcher: CoroutineDispatcher, private val innerClient: Client, ) : MediaPreviewService { + override val mediaPreviewConfigFlow: StateFlow = + innerClient + .getMediaPreviewConfigFlow() + .stateIn(sessionCoroutineScope, started = SharingStarted.Lazily, initialValue = MediaPreviewConfig.DEFAULT) + override suspend fun fetchMediaPreviewConfig(): Result = withContext(sessionDispatcher) { runCatchingExceptions { innerClient.fetchMediaPreviewConfig()?.into() } } - override fun getMediaPreviewConfigFlow(): Flow = innerClient.getMediaPreviewConfigFlow() - - override suspend fun getMediaPreviewValue(): MediaPreviewValue? = innerClient.getMediaPreviewDisplayPolicy()?.into() - override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result = withContext(sessionDispatcher) { runCatchingExceptions { innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into()) } } - override suspend fun getHideInviteAvatars(): Boolean = innerClient.getInviteAvatarsDisplayPolicy() == InviteAvatars.OFF - override suspend fun setHideInviteAvatars(hide: Boolean): Result = withContext(sessionDispatcher) { runCatchingExceptions { val inviteAvatars = if (hide) InviteAvatars.OFF else InviteAvatars.ON @@ -61,7 +64,9 @@ private fun RustMediaPreviewConfig.into(): MediaPreviewConfig { private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow { subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener { override fun onChange(mediaPreviewConfig: RustMediaPreviewConfig?) { - trySend(mediaPreviewConfig?.into()) + if (mediaPreviewConfig != null) { + trySend(mediaPreviewConfig.into()) + } } }) } @@ -81,4 +86,3 @@ private fun MediaPreviews.into(): MediaPreviewValue { MediaPreviews.PRIVATE -> MediaPreviewValue.Private } } - 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 11b95a647e..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 @@ -17,8 +17,6 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.encryption.EncryptionService -import io.element.android.libraries.matrix.api.media.MediaPreviewConfig -import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.notification.NotificationService @@ -96,7 +94,6 @@ class FakeMatrixClient( private val canReportRoomLambda: () -> Boolean = { false }, private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, override val ignoredUsersFlow: StateFlow> = MutableStateFlow(persistentListOf()), - ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -246,7 +243,6 @@ class FakeMatrixClient( return RoomMembershipObserver() } - // Mocks fun givenCreateRoomResult(result: Result) { 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 index 7e41b03e41..d8e9114817 100644 --- 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 @@ -12,34 +12,19 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.MediaPreviewValue import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow class FakeMediaPreviewService( + override val mediaPreviewConfigFlow: StateFlow = MutableStateFlow(MediaPreviewConfig.DEFAULT), private val fetchMediaPreviewConfigResult: () -> Result = { lambdaError() }, - private val mediaPreviewConfigFlow: Flow = flowOf(null), - private val getMediaPreviewValue: ()-> MediaPreviewValue? = { null }, - private val getHideInviteAvatars: () -> Boolean = { false }, private val setMediaPreviewValueResult: (MediaPreviewValue) -> Result = { lambdaError() }, private val setHideInviteAvatarsResult: (Boolean) -> Result = { lambdaError() }, -): MediaPreviewService { - +) : MediaPreviewService { override suspend fun fetchMediaPreviewConfig(): Result = simulateLongTask { fetchMediaPreviewConfigResult() } - override fun getMediaPreviewConfigFlow(): Flow { - return mediaPreviewConfigFlow - } - - override suspend fun getMediaPreviewValue(): MediaPreviewValue? = simulateLongTask { - getMediaPreviewValue.invoke() - } - - override suspend fun getHideInviteAvatars(): Boolean = simulateLongTask { - getHideInviteAvatars.invoke() - } - override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result = simulateLongTask { setMediaPreviewValueResult(mediaPreviewValue) } 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 59a074c99b..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,9 +22,13 @@ interface AppPreferencesStore { suspend fun setTheme(theme: String) fun getThemeFlow(): 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) 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 dd2f7eb760..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 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 cb3c0a78f0..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 From a95b89ab6239296abe245e1ccd891556014d562b Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 30 Jun 2025 21:54:17 +0200 Subject: [PATCH 07/10] change (media preview config) : clean code --- .../android/features/home/impl/roomlist/RoomListPresenter.kt | 2 -- .../preferences/impl/store/DefaultAppPreferencesStore.kt | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) 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 444ddb7a86..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 @@ -36,8 +36,6 @@ 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.designsystem.utils.snackbar.SnackbarDispatcher -import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState 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 1ea7c1f874..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,12 +85,14 @@ class DefaultAppPreferencesStore @Inject constructor( } } + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") override fun getHideInviteAvatarsFlow(): Flow { return store.data.map { prefs -> prefs[hideInviteAvatarsKey] } } + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") override suspend fun setHideInviteAvatars(hide: Boolean?) { store.edit { prefs -> if (hide != null) { @@ -101,6 +103,7 @@ class DefaultAppPreferencesStore @Inject constructor( } } + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) { store.edit { prefs -> if (mediaPreviewValue != null) { @@ -111,6 +114,7 @@ class DefaultAppPreferencesStore @Inject constructor( } } + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") override fun getTimelineMediaPreviewValueFlow(): Flow { return store.data.map { prefs -> prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } From eef0fdfd8289329fc6d2a3695a8439e90e5a07a9 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 1 Jul 2025 07:14:32 +0000 Subject: [PATCH 08/10] Update screenshots --- ...preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png | 3 +++ ...preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png | 3 +++ ...references.impl.advanced_AdvancedSettingsViewLight_6_en.png | 3 +++ ...references.impl.advanced_AdvancedSettingsViewLight_7_en.png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en.png 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 From 9b593e4bb9cc01a438584258b17261dc76b153dd Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 1 Jul 2025 11:22:03 +0200 Subject: [PATCH 09/10] change (media preview config) : address review remarks --- .../appnav/loggedin/MediaPreviewConfigMigration.kt | 2 +- .../preferences/impl/advanced/AdvancedSettingsView.kt | 11 ++++------- .../impl/advanced/MediaPreviewConfigStateStore.kt | 6 ++++-- .../matrix/impl/media/RustMediaPreviewService.kt | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) 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 index 54299acc74..d9ed15318a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt @@ -52,7 +52,7 @@ class MediaPreviewConfigMigration @Inject constructor( } } .onFailure { - Timber.d("Couldn't perform migration, failed to fetch media preview config.") + Timber.e(it, "Couldn't perform migration, failed to fetch media preview config.") } } } 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 c92f50bd2e..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,6 @@ package io.element.android.features.preferences.impl.advanced -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -143,12 +142,10 @@ private fun ModerationAndSafety( title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), hasDivider = false, description = { - Row { - ListSupportingText( - text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), - contentPadding = ListSupportingTextDefaults.Padding.None, - ) - } + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) } ) ListItem( 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 index 715938b93b..49f199ec3f 100644 --- 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 @@ -88,8 +88,9 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor( override fun setHideInviteAvatars(hide: Boolean) { sessionCoroutineScope.launch { - Timber.d("Setting hide invite avatars to $hide") val prevHideInviteAvatars = hideInviteAvatars.value + if (prevHideInviteAvatars == hide) return@launch + Timber.d("Setting hide invite avatars to $hide") hideInviteAvatars.value = hide runUpdatingState(setHideInviteAvatarsAction) { mediaPreviewService @@ -104,8 +105,9 @@ class DefaultMediaPreviewConfigStateStore @Inject constructor( override fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { sessionCoroutineScope.launch { - Timber.d("Setting timeline media preview value to $value") val prevTimelineMediaPreviewValue = timelineMediaPreviewValue.value + if (prevTimelineMediaPreviewValue == value) return@launch + Timber.d("Setting timeline media preview value to $value") timelineMediaPreviewValue.value = value runUpdatingState(setTimelineMediaPreviewAction) { 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 index c18f85712f..e4e9bc07bd 100644 --- 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 @@ -56,7 +56,7 @@ class RustMediaPreviewService( private fun RustMediaPreviewConfig.into(): MediaPreviewConfig { return MediaPreviewConfig( - mediaPreviewValue = this@into.mediaPreviews.into(), + mediaPreviewValue = mediaPreviews.into(), hideInviteAvatar = inviteAvatars == InviteAvatars.OFF ) } From 805273d2b9f1488b1749d0e214c8ede53a8a55f6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 1 Jul 2025 12:58:24 +0200 Subject: [PATCH 10/10] change (media preview config) : fix warning on deprecated method --- .../libraries/preferences/test/InMemoryAppPreferencesStore.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 b474ec63ea..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 @@ -55,18 +55,22 @@ class InMemoryAppPreferencesStore( return theme } + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") override fun getHideInviteAvatarsFlow(): Flow { return hideInviteAvatars } + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") 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 }