Merge pull request #4962 from element-hq/feature/fga/csam_preferences_server
Change : sync moderation and safety preferences with server
This commit is contained in:
commit
23f2804341
36 changed files with 1095 additions and 127 deletions
|
|
@ -41,6 +41,7 @@ import dagger.assisted.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.appnav.loggedin.LoggedInNode
|
import io.element.android.appnav.loggedin.LoggedInNode
|
||||||
|
import io.element.android.appnav.loggedin.MediaPreviewConfigMigration
|
||||||
import io.element.android.appnav.loggedin.SendQueues
|
import io.element.android.appnav.loggedin.SendQueues
|
||||||
import io.element.android.appnav.room.RoomFlowNode
|
import io.element.android.appnav.room.RoomFlowNode
|
||||||
import io.element.android.appnav.room.RoomNavigationTarget
|
import io.element.android.appnav.room.RoomNavigationTarget
|
||||||
|
|
@ -123,6 +124,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
private val sendingQueue: SendQueues,
|
private val sendingQueue: SendQueues,
|
||||||
private val logoutEntryPoint: LogoutEntryPoint,
|
private val logoutEntryPoint: LogoutEntryPoint,
|
||||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||||
|
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
|
||||||
snackbarDispatcher: SnackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -188,6 +190,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
||||||
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
||||||
matrixClient.sessionVerificationService().setListener(verificationListener)
|
matrixClient.sessionVerificationService().setListener(verificationListener)
|
||||||
|
mediaPreviewConfigMigration()
|
||||||
|
|
||||||
ftueService.state
|
ftueService.state
|
||||||
.onEach { ftueState ->
|
.onEach { ftueState ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This migration is temporary, will be safe to remove after some time.
|
||||||
|
* The goal is to set the server config if it's not set, and remove the local data.
|
||||||
|
*/
|
||||||
|
class MediaPreviewConfigMigration @Inject constructor(
|
||||||
|
private val mediaPreviewService: MediaPreviewService,
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
operator fun invoke() = sessionCoroutineScope.launch {
|
||||||
|
val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first()
|
||||||
|
val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first()
|
||||||
|
if (hideInviteAvatars == null && mediaPreviewValue == null) {
|
||||||
|
// No local data, abort.
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
mediaPreviewService
|
||||||
|
.fetchMediaPreviewConfig()
|
||||||
|
.onSuccess { config ->
|
||||||
|
if (config != null) {
|
||||||
|
appPreferencesStore.setHideInviteAvatars(null)
|
||||||
|
appPreferencesStore.setTimelineMediaPreviewValue(null)
|
||||||
|
} else {
|
||||||
|
if (hideInviteAvatars != null) {
|
||||||
|
mediaPreviewService.setHideInviteAvatars(hideInviteAvatars)
|
||||||
|
appPreferencesStore.setHideInviteAvatars(null)
|
||||||
|
}
|
||||||
|
if (mediaPreviewValue != null) {
|
||||||
|
mediaPreviewService.setMediaPreviewValue(mediaPreviewValue)
|
||||||
|
appPreferencesStore.setTimelineMediaPreviewValue(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "Couldn't perform migration, failed to fetch media preview config.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent.ShowConfirmation
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||||
|
|
@ -103,8 +104,11 @@ class RoomListPresenter @Inject constructor(
|
||||||
|
|
||||||
// Avatar indicator
|
// Avatar indicator
|
||||||
val hideInvitesAvatar by remember {
|
val hideInvitesAvatar by remember {
|
||||||
appPreferencesStore.getHideInviteAvatarsFlow()
|
client
|
||||||
}.collectAsState(initial = false)
|
.mediaPreviewService()
|
||||||
|
.mediaPreviewConfigFlow
|
||||||
|
.mapState { config -> config.hideInviteAvatar }
|
||||||
|
}.collectAsState()
|
||||||
|
|
||||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||||
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
|
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runUpdatingState
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -49,7 +50,6 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
||||||
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
|
@ -67,7 +67,6 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
private val forgetRoom: ForgetRoom,
|
private val forgetRoom: ForgetRoom,
|
||||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
|
||||||
private val seenInvitesStore: SeenInvitesStore,
|
private val seenInvitesStore: SeenInvitesStore,
|
||||||
) : Presenter<JoinRoomState> {
|
) : Presenter<JoinRoomState> {
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -94,8 +93,11 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
var knockMessage by rememberSaveable { mutableStateOf("") }
|
var knockMessage by rememberSaveable { mutableStateOf("") }
|
||||||
var isDismissingContent by remember { mutableStateOf(false) }
|
var isDismissingContent by remember { mutableStateOf(false) }
|
||||||
val hideInviteAvatars by remember {
|
val hideInviteAvatars by remember {
|
||||||
appPreferencesStore.getHideInviteAvatarsFlow()
|
matrixClient
|
||||||
}.collectAsState(initial = false)
|
.mediaPreviewService()
|
||||||
|
.mediaPreviewConfigFlow
|
||||||
|
.mapState { config -> config.hideInviteAvatar }
|
||||||
|
}.collectAsState()
|
||||||
val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() }
|
val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() }
|
||||||
|
|
||||||
val contentState by produceState<ContentState>(
|
val contentState by produceState<ContentState>(
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRoom
|
import io.element.android.libraries.matrix.api.room.join.JoinRoom
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -37,7 +36,6 @@ object JoinRoomModule {
|
||||||
forgetRoom: ForgetRoom,
|
forgetRoom: ForgetRoom,
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
buildMeta: BuildMeta,
|
buildMeta: BuildMeta,
|
||||||
appPreferencesStore: AppPreferencesStore,
|
|
||||||
seenInvitesStore: SeenInvitesStore,
|
seenInvitesStore: SeenInvitesStore,
|
||||||
): JoinRoomPresenter.Factory {
|
): JoinRoomPresenter.Factory {
|
||||||
return object : JoinRoomPresenter.Factory {
|
return object : JoinRoomPresenter.Factory {
|
||||||
|
|
@ -61,7 +59,6 @@ object JoinRoomModule {
|
||||||
cancelKnockRoom = cancelKnockRoom,
|
cancelKnockRoom = cancelKnockRoom,
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
appPreferencesStore = appPreferencesStore,
|
|
||||||
seenInvitesStore = seenInvitesStore,
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,6 @@ import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo
|
||||||
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
|
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
|
||||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||||
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.any
|
import io.element.android.tests.testutils.lambda.any
|
||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
|
|
@ -1057,7 +1055,6 @@ class JoinRoomPresenterTest {
|
||||||
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
||||||
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
|
||||||
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||||
): JoinRoomPresenter {
|
): JoinRoomPresenter {
|
||||||
return JoinRoomPresenter(
|
return JoinRoomPresenter(
|
||||||
|
|
@ -1073,7 +1070,6 @@ class JoinRoomPresenterTest {
|
||||||
forgetRoom = forgetRoom,
|
forgetRoom = forgetRoom,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
appPreferencesStore = appPreferencesStore,
|
|
||||||
seenInvitesStore = seenInvitesStore,
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,16 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
|
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
import kotlinx.collections.immutable.toImmutableSet
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class TimelineProtectionPresenter @Inject constructor(
|
class TimelineProtectionPresenter @Inject constructor(
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
private val mediaPreviewService: MediaPreviewService,
|
||||||
private val room: BaseRoom,
|
private val room: BaseRoom,
|
||||||
) : Presenter<TimelineProtectionState> {
|
) : Presenter<TimelineProtectionState> {
|
||||||
private val allowedEvents = mutableStateOf<Set<EventId>>(setOf())
|
private val allowedEvents = mutableStateOf<Set<EventId>>(setOf())
|
||||||
|
|
@ -31,8 +31,8 @@ class TimelineProtectionPresenter @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TimelineProtectionState {
|
override fun present(): TimelineProtectionState {
|
||||||
val mediaPreviewValue = remember {
|
val mediaPreviewValue = remember {
|
||||||
appPreferencesStore.getTimelineMediaPreviewValueFlow()
|
mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue }
|
||||||
}.collectAsState(initial = MediaPreviewValue.On)
|
}.collectAsState()
|
||||||
val roomInfo = room.roomInfoFlow.collectAsState()
|
val roomInfo = room.roomInfoFlow.collectAsState()
|
||||||
val protectionState by remember {
|
val protectionState by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,19 @@
|
||||||
package io.element.android.features.messages.impl.timeline.protection
|
package io.element.android.features.messages.impl.timeline.protection
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -38,10 +40,10 @@ class TimelineProtectionPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - media preview value off`() = runTest {
|
fun `present - media preview value off`() = runTest {
|
||||||
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Off)
|
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false)
|
||||||
val presenter = createPresenter(appPreferencesStore)
|
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
|
||||||
|
val presenter = createPresenter(mediaPreviewService = mediaPreviewService)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
|
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
|
||||||
// ShowContent with null should have no effect.
|
// ShowContent with null should have no effect.
|
||||||
|
|
@ -54,11 +56,11 @@ class TimelineProtectionPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - media preview value private in public room`() = runTest {
|
fun `present - media preview value private in public room`() = runTest {
|
||||||
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private)
|
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false)
|
||||||
|
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
|
||||||
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Public))
|
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Public))
|
||||||
val presenter = createPresenter(appPreferencesStore, room)
|
val presenter = createPresenter(mediaPreviewService = mediaPreviewService, room = room)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
|
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
|
||||||
// ShowContent with null should have no effect.
|
// ShowContent with null should have no effect.
|
||||||
|
|
@ -71,9 +73,10 @@ class TimelineProtectionPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - media preview value private in non public room`() = runTest {
|
fun `present - media preview value private in non public room`() = runTest {
|
||||||
val appPreferencesStore = InMemoryAppPreferencesStore(timelineMediaPreviewValue = MediaPreviewValue.Private)
|
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false)
|
||||||
|
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
|
||||||
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite))
|
val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite))
|
||||||
val presenter = createPresenter(appPreferencesStore, room)
|
val presenter = createPresenter(mediaPreviewService = mediaPreviewService, room = room)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll)
|
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll)
|
||||||
|
|
@ -84,10 +87,10 @@ class TimelineProtectionPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPresenter(
|
private fun createPresenter(
|
||||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
|
||||||
room: BaseRoom = FakeBaseRoom(),
|
room: BaseRoom = FakeBaseRoom(),
|
||||||
|
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||||
) = TimelineProtectionPresenter(
|
) = TimelineProtectionPresenter(
|
||||||
appPreferencesStore = appPreferencesStore,
|
mediaPreviewService = mediaPreviewService,
|
||||||
room = room,
|
room = room,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,23 +12,26 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.compound.theme.Theme
|
import io.element.android.compound.theme.Theme
|
||||||
import io.element.android.compound.theme.mapToTheme
|
import io.element.android.compound.theme.mapToTheme
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AdvancedSettingsPresenter @Inject constructor(
|
class AdvancedSettingsPresenter @Inject constructor(
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||||
|
private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore,
|
||||||
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
) : Presenter<AdvancedSettingsState> {
|
) : Presenter<AdvancedSettingsState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AdvancedSettingsState {
|
override fun present(): AdvancedSettingsState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
|
||||||
val isDeveloperModeEnabled by remember {
|
val isDeveloperModeEnabled by remember {
|
||||||
appPreferencesStore.isDeveloperModeEnabledFlow()
|
appPreferencesStore.isDeveloperModeEnabledFlow()
|
||||||
}.collectAsState(initial = false)
|
}.collectAsState(initial = false)
|
||||||
|
|
@ -41,13 +44,8 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
val theme = remember {
|
val theme = remember {
|
||||||
appPreferencesStore.getThemeFlow().mapToTheme()
|
appPreferencesStore.getThemeFlow().mapToTheme()
|
||||||
}.collectAsState(initial = Theme.System)
|
}.collectAsState(initial = Theme.System)
|
||||||
val hideInviteAvatars by remember {
|
|
||||||
appPreferencesStore.getHideInviteAvatarsFlow()
|
|
||||||
}.collectAsState(false)
|
|
||||||
|
|
||||||
val timelineMediaPreviewValue by remember {
|
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
|
||||||
appPreferencesStore.getTimelineMediaPreviewValueFlow()
|
|
||||||
}.collectAsState(initial = MediaPreviewValue.On)
|
|
||||||
|
|
||||||
val themeOption by remember {
|
val themeOption by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
|
@ -61,28 +59,24 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
|
|
||||||
fun handleEvents(event: AdvancedSettingsEvents) {
|
fun handleEvents(event: AdvancedSettingsEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> sessionCoroutineScope.launch {
|
||||||
appPreferencesStore.setDeveloperModeEnabled(event.enabled)
|
appPreferencesStore.setDeveloperModeEnabled(event.enabled)
|
||||||
}
|
}
|
||||||
is AdvancedSettingsEvents.SetSharePresenceEnabled -> localCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetSharePresenceEnabled -> sessionCoroutineScope.launch {
|
||||||
sessionPreferencesStore.setSharePresence(event.enabled)
|
sessionPreferencesStore.setSharePresence(event.enabled)
|
||||||
}
|
}
|
||||||
is AdvancedSettingsEvents.SetCompressMedia -> localCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetCompressMedia -> sessionCoroutineScope.launch {
|
||||||
sessionPreferencesStore.setCompressMedia(event.compress)
|
sessionPreferencesStore.setCompressMedia(event.compress)
|
||||||
}
|
}
|
||||||
is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetTheme -> sessionCoroutineScope.launch {
|
||||||
when (event.theme) {
|
when (event.theme) {
|
||||||
ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name)
|
ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name)
|
||||||
ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name)
|
ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name)
|
||||||
ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name)
|
ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is AdvancedSettingsEvents.SetHideInviteAvatars -> localCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value)
|
||||||
appPreferencesStore.setHideInviteAvatars(event.value)
|
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value)
|
||||||
}
|
|
||||||
is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> localCoroutineScope.launch {
|
|
||||||
appPreferencesStore.setTimelineMediaPreviewValue(event.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,9 +85,8 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||||
doesCompressMedia = doesCompressMedia,
|
doesCompressMedia = doesCompressMedia,
|
||||||
theme = themeOption,
|
theme = themeOption,
|
||||||
hideInviteAvatars = hideInviteAvatars,
|
mediaPreviewConfigState = mediaPreviewConfigState,
|
||||||
timelineMediaPreviewValue = timelineMediaPreviewValue,
|
eventSink = ::handleEvents,
|
||||||
eventSink = { handleEvents(it) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
|
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
data class AdvancedSettingsState(
|
data class AdvancedSettingsState(
|
||||||
|
|
@ -19,8 +18,7 @@ data class AdvancedSettingsState(
|
||||||
val isSharePresenceEnabled: Boolean,
|
val isSharePresenceEnabled: Boolean,
|
||||||
val doesCompressMedia: Boolean,
|
val doesCompressMedia: Boolean,
|
||||||
val theme: ThemeOption,
|
val theme: ThemeOption,
|
||||||
val hideInviteAvatars: Boolean,
|
val mediaPreviewConfigState: MediaPreviewConfigState,
|
||||||
val timelineMediaPreviewValue: MediaPreviewValue,
|
|
||||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.features.preferences.impl.advanced
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
|
||||||
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
|
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
|
||||||
|
|
@ -18,7 +19,9 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||||
aAdvancedSettingsState(isSharePresenceEnabled = true),
|
aAdvancedSettingsState(isSharePresenceEnabled = true),
|
||||||
aAdvancedSettingsState(doesCompressMedia = true),
|
aAdvancedSettingsState(doesCompressMedia = true),
|
||||||
aAdvancedSettingsState(hideInviteAvatars = true),
|
aAdvancedSettingsState(hideInviteAvatars = true),
|
||||||
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off)
|
aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off),
|
||||||
|
aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading),
|
||||||
|
aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,16 +29,22 @@ fun aAdvancedSettingsState(
|
||||||
isDeveloperModeEnabled: Boolean = false,
|
isDeveloperModeEnabled: Boolean = false,
|
||||||
isSharePresenceEnabled: Boolean = false,
|
isSharePresenceEnabled: Boolean = false,
|
||||||
doesCompressMedia: Boolean = false,
|
doesCompressMedia: Boolean = false,
|
||||||
hideInviteAvatars: Boolean = false,
|
|
||||||
theme: ThemeOption = ThemeOption.System,
|
theme: ThemeOption = ThemeOption.System,
|
||||||
|
hideInviteAvatars: Boolean = false,
|
||||||
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
||||||
|
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
setHideInviteAvatarsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (AdvancedSettingsEvents) -> Unit = {},
|
eventSink: (AdvancedSettingsEvents) -> Unit = {},
|
||||||
) = AdvancedSettingsState(
|
) = AdvancedSettingsState(
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
isSharePresenceEnabled = isSharePresenceEnabled,
|
isSharePresenceEnabled = isSharePresenceEnabled,
|
||||||
doesCompressMedia = doesCompressMedia,
|
doesCompressMedia = doesCompressMedia,
|
||||||
theme = theme,
|
theme = theme,
|
||||||
hideInviteAvatars = hideInviteAvatars,
|
mediaPreviewConfigState = MediaPreviewConfigState(
|
||||||
timelineMediaPreviewValue = timelineMediaPreviewValue,
|
hideInviteAvatars = hideInviteAvatars,
|
||||||
|
timelineMediaPreviewValue = timelineMediaPreviewValue,
|
||||||
|
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction,
|
||||||
|
setHideInviteAvatarsAction = setHideInviteAvatarsAction
|
||||||
|
),
|
||||||
eventSink = eventSink
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
|
|
||||||
package io.element.android.features.preferences.impl.advanced
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
|
@ -27,6 +29,10 @@ import io.element.android.libraries.designsystem.theme.components.ListSectionHea
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
|
import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.analytics.compose.LocalAnalyticsService
|
import io.element.android.services.analytics.compose.LocalAnalyticsService
|
||||||
|
|
@ -40,10 +46,21 @@ fun AdvancedSettingsView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val analyticsService = LocalAnalyticsService.current
|
val analyticsService = LocalAnalyticsService.current
|
||||||
|
|
||||||
|
val snackbarDispatcher = LocalSnackbarDispatcher.current
|
||||||
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage)
|
||||||
|
|
||||||
PreferencePage(
|
PreferencePage(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
title = stringResource(id = CommonStrings.common_advanced_settings)
|
title = stringResource(id = CommonStrings.common_advanced_settings),
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(
|
||||||
|
snackbarHostState,
|
||||||
|
modifier = Modifier.navigationBarsPadding()
|
||||||
|
)
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
PreferenceDropdown(
|
PreferenceDropdown(
|
||||||
title = stringResource(id = CommonStrings.common_appearance),
|
title = stringResource(id = CommonStrings.common_appearance),
|
||||||
|
|
@ -115,10 +132,11 @@ private fun ModerationAndSafety(
|
||||||
) {
|
) {
|
||||||
PreferenceSwitch(
|
PreferenceSwitch(
|
||||||
title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title),
|
title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title),
|
||||||
isChecked = state.hideInviteAvatars,
|
isChecked = state.mediaPreviewConfigState.hideInviteAvatars,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it))
|
state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it))
|
||||||
},
|
},
|
||||||
|
enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading()
|
||||||
)
|
)
|
||||||
ListSectionHeader(
|
ListSectionHeader(
|
||||||
title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title),
|
title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title),
|
||||||
|
|
@ -132,24 +150,36 @@ private fun ModerationAndSafety(
|
||||||
)
|
)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
|
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
|
||||||
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Off, compact = true),
|
leadingContent = ListItemContent.RadioButton(
|
||||||
|
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off,
|
||||||
|
compact = true
|
||||||
|
),
|
||||||
onClick = {
|
onClick = {
|
||||||
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
|
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
|
||||||
},
|
},
|
||||||
|
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
|
||||||
)
|
)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
|
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
|
||||||
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.Private, compact = true),
|
leadingContent = ListItemContent.RadioButton(
|
||||||
|
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private,
|
||||||
|
compact = true
|
||||||
|
),
|
||||||
onClick = {
|
onClick = {
|
||||||
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
|
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
|
||||||
},
|
},
|
||||||
|
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
|
||||||
)
|
)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
|
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
|
||||||
leadingContent = ListItemContent.RadioButton(selected = state.timelineMediaPreviewValue == MediaPreviewValue.On, compact = true),
|
leadingContent = ListItemContent.RadioButton(
|
||||||
|
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On,
|
||||||
|
compact = true
|
||||||
|
),
|
||||||
onClick = {
|
onClick = {
|
||||||
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
|
state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
|
||||||
},
|
},
|
||||||
|
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class MediaPreviewConfigState(
|
||||||
|
val hideInviteAvatars: Boolean,
|
||||||
|
val timelineMediaPreviewValue: MediaPreviewValue,
|
||||||
|
val setHideInviteAvatarsAction: AsyncAction<Unit>,
|
||||||
|
val setTimelineMediaPreviewAction: AsyncAction<Unit>,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface MediaPreviewConfigStateStore {
|
||||||
|
@Composable
|
||||||
|
fun state(): MediaPreviewConfigState
|
||||||
|
fun setHideInviteAvatars(hide: Boolean)
|
||||||
|
fun setTimelineMediaPreviewValue(value: MediaPreviewValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(SessionScope::class)
|
||||||
|
@SingleIn(SessionScope::class)
|
||||||
|
class DefaultMediaPreviewConfigStateStore @Inject constructor(
|
||||||
|
@SessionCoroutineScope
|
||||||
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
|
private val mediaPreviewService: MediaPreviewService,
|
||||||
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
|
) : MediaPreviewConfigStateStore {
|
||||||
|
private val hideInviteAvatars = mutableStateOf(false)
|
||||||
|
private val timelineMediaPreviewValue = mutableStateOf(MediaPreviewValue.On)
|
||||||
|
private val setHideInviteAvatarsAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||||
|
private val setTimelineMediaPreviewAction = mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
|
||||||
|
|
||||||
|
init {
|
||||||
|
val configFlow = mediaPreviewService.mediaPreviewConfigFlow
|
||||||
|
val hideInviteAvatarsFlow = configFlow.map { it.hideInviteAvatar }.distinctUntilChanged()
|
||||||
|
val timelineMediaPreviewFlow = configFlow.map { it.mediaPreviewValue }.distinctUntilChanged()
|
||||||
|
|
||||||
|
hideInviteAvatarsFlow
|
||||||
|
.onEach {
|
||||||
|
Timber.d("Hide invite avatars changed to $it")
|
||||||
|
hideInviteAvatars.value = it
|
||||||
|
}
|
||||||
|
.launchIn(sessionCoroutineScope)
|
||||||
|
|
||||||
|
timelineMediaPreviewFlow
|
||||||
|
.onEach {
|
||||||
|
Timber.d("Timeline media preview value changed to $it")
|
||||||
|
timelineMediaPreviewValue.value = it
|
||||||
|
}
|
||||||
|
.launchIn(sessionCoroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun state(): MediaPreviewConfigState {
|
||||||
|
return MediaPreviewConfigState(
|
||||||
|
hideInviteAvatars = hideInviteAvatars.value,
|
||||||
|
timelineMediaPreviewValue = timelineMediaPreviewValue.value,
|
||||||
|
setHideInviteAvatarsAction = setHideInviteAvatarsAction.value,
|
||||||
|
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setHideInviteAvatars(hide: Boolean) {
|
||||||
|
sessionCoroutineScope.launch {
|
||||||
|
val prevHideInviteAvatars = hideInviteAvatars.value
|
||||||
|
if (prevHideInviteAvatars == hide) return@launch
|
||||||
|
Timber.d("Setting hide invite avatars to $hide")
|
||||||
|
hideInviteAvatars.value = hide
|
||||||
|
runUpdatingState(setHideInviteAvatarsAction) {
|
||||||
|
mediaPreviewService
|
||||||
|
.setHideInviteAvatars(hide)
|
||||||
|
.onFailure {
|
||||||
|
hideInviteAvatars.value = prevHideInviteAvatars
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_something_went_wrong_message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTimelineMediaPreviewValue(value: MediaPreviewValue) {
|
||||||
|
sessionCoroutineScope.launch {
|
||||||
|
val prevTimelineMediaPreviewValue = timelineMediaPreviewValue.value
|
||||||
|
if (prevTimelineMediaPreviewValue == value) return@launch
|
||||||
|
Timber.d("Setting timeline media preview value to $value")
|
||||||
|
timelineMediaPreviewValue.value = value
|
||||||
|
runUpdatingState(setTimelineMediaPreviewAction) {
|
||||||
|
mediaPreviewService
|
||||||
|
.setMediaPreviewValue(value)
|
||||||
|
.onFailure {
|
||||||
|
timelineMediaPreviewValue.value = prevTimelineMediaPreviewValue
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_something_went_wrong_message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,12 @@ import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -34,6 +36,10 @@ class AdvancedSettingsPresenterTest {
|
||||||
assertThat(isSharePresenceEnabled).isTrue()
|
assertThat(isSharePresenceEnabled).isTrue()
|
||||||
assertThat(doesCompressMedia).isTrue()
|
assertThat(doesCompressMedia).isTrue()
|
||||||
assertThat(theme).isEqualTo(ThemeOption.System)
|
assertThat(theme).isEqualTo(ThemeOption.System)
|
||||||
|
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
|
||||||
|
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
|
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
|
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,49 +130,92 @@ class AdvancedSettingsPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - hide invite avatars`() = runTest {
|
fun `present - hide invite avatars`() = runTest {
|
||||||
val presenter = createAdvancedSettingsPresenter()
|
val mediaPreviewStore = FakeMediaPreviewConfigStateStore()
|
||||||
|
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(hideInviteAvatars).isFalse()
|
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
|
||||||
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true))
|
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true))
|
||||||
}
|
}
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(hideInviteAvatars).isTrue()
|
assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
|
||||||
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(false))
|
eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(false))
|
||||||
}
|
}
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(hideInviteAvatars).isFalse()
|
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - timeline media preview value`() = runTest {
|
||||||
|
val mediaPreviewStore = FakeMediaPreviewConfigStateStore()
|
||||||
|
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
|
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
|
||||||
|
}
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
|
||||||
|
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
|
||||||
|
}
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo(
|
||||||
|
listOf(MediaPreviewValue.Off, MediaPreviewValue.Private)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - media preview state with custom initial values`() = runTest {
|
||||||
|
val mediaPreviewStore = FakeMediaPreviewConfigStateStore(
|
||||||
|
hideInviteAvatarsValue = true,
|
||||||
|
timelineMediaPreviewValue = MediaPreviewValue.Private
|
||||||
|
)
|
||||||
|
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
with(awaitItem()) {
|
||||||
|
assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue()
|
||||||
|
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - timeline media preview value`() = runTest {
|
fun `present - async actions state`() = runTest {
|
||||||
val presenter = createAdvancedSettingsPresenter()
|
val mediaPreviewStore = FakeMediaPreviewConfigStateStore(
|
||||||
|
setHideInviteAvatarsActionValue = AsyncAction.Loading,
|
||||||
|
setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit)
|
||||||
|
)
|
||||||
|
val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
with(awaitItem()) {
|
with(awaitItem()) {
|
||||||
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading)
|
||||||
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
|
assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit))
|
||||||
}
|
|
||||||
with(awaitItem()) {
|
|
||||||
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
|
|
||||||
eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
|
|
||||||
}
|
|
||||||
with(awaitItem()) {
|
|
||||||
assertThat(timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createAdvancedSettingsPresenter(
|
private fun CoroutineScope.createAdvancedSettingsPresenter(
|
||||||
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
|
mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(),
|
||||||
) = AdvancedSettingsPresenter(
|
) = AdvancedSettingsPresenter(
|
||||||
appPreferencesStore = appPreferencesStore,
|
appPreferencesStore = appPreferencesStore,
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
|
mediaPreviewConfigStateStore = mediaPreviewConfigStateStore,
|
||||||
|
sessionCoroutineScope = this,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.Interaction
|
import im.vector.app.features.analytics.plan.Interaction
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -131,7 +132,7 @@ class AdvancedSettingsViewTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Config(qualifiers = "h640dp")
|
@Config(qualifiers = "h1080dp")
|
||||||
fun `clicking on hide invite avatars emits the expected event`() {
|
fun `clicking on hide invite avatars emits the expected event`() {
|
||||||
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
|
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
|
||||||
rule.setAdvancedSettingsView(
|
rule.setAdvancedSettingsView(
|
||||||
|
|
@ -145,8 +146,8 @@ class AdvancedSettingsViewTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Config(qualifiers = "h1024dp")
|
@Config(qualifiers = "h1080dp")
|
||||||
fun `clicking on timeline media preview emits the expected event`() {
|
fun `clicking on timeline media preview always hide emits the expected event`() {
|
||||||
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
|
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
|
||||||
rule.setAdvancedSettingsView(
|
rule.setAdvancedSettingsView(
|
||||||
state = aAdvancedSettingsState(
|
state = aAdvancedSettingsState(
|
||||||
|
|
@ -157,6 +158,65 @@ class AdvancedSettingsViewTest {
|
||||||
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
|
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
|
||||||
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
|
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "h1080dp")
|
||||||
|
fun `clicking on timeline media preview private rooms emits the expected event`() {
|
||||||
|
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
|
||||||
|
rule.setAdvancedSettingsView(
|
||||||
|
state = aAdvancedSettingsState(
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
timelineMediaPreviewValue = MediaPreviewValue.On
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms)
|
||||||
|
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "h1080dp")
|
||||||
|
fun `clicking on timeline media preview always show emits the expected event`() {
|
||||||
|
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
|
||||||
|
rule.setAdvancedSettingsView(
|
||||||
|
state = aAdvancedSettingsState(
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
timelineMediaPreviewValue = MediaPreviewValue.Off
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show)
|
||||||
|
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "h1080dp")
|
||||||
|
fun `hide invite avatars toggle is disabled when action is loading`() {
|
||||||
|
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false)
|
||||||
|
rule.setAdvancedSettingsView(
|
||||||
|
state = aAdvancedSettingsState(
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
hideInviteAvatars = false,
|
||||||
|
setHideInviteAvatarsAction = AsyncAction.Loading
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// The toggle should be disabled, so clicking should not emit any events
|
||||||
|
rule.clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "h1080dp")
|
||||||
|
fun `timeline media preview options are disabled when action is loading`() {
|
||||||
|
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false)
|
||||||
|
rule.setAdvancedSettingsView(
|
||||||
|
state = aAdvancedSettingsState(
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
timelineMediaPreviewValue = MediaPreviewValue.On,
|
||||||
|
setTimelineMediaPreviewAction = AsyncAction.Loading
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// The options should be disabled, so clicking should not emit any events
|
||||||
|
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
|
||||||
|
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAdvancedSettingsView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAdvancedSettingsView(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
|
||||||
|
class FakeMediaPreviewConfigStateStore(
|
||||||
|
hideInviteAvatarsValue: Boolean = false,
|
||||||
|
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
||||||
|
setHideInviteAvatarsActionValue: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
setTimelineMediaPreviewActionValue: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
) : MediaPreviewConfigStateStore {
|
||||||
|
private val hideInviteAvatars = mutableStateOf(hideInviteAvatarsValue)
|
||||||
|
private val timelineMediaPreviewValue = mutableStateOf(timelineMediaPreviewValue)
|
||||||
|
private val setHideInviteAvatarsAction = mutableStateOf(setHideInviteAvatarsActionValue)
|
||||||
|
private val setTimelineMediaPreviewAction = mutableStateOf(setTimelineMediaPreviewActionValue)
|
||||||
|
|
||||||
|
private val setHideInviteAvatarsEvents = mutableListOf<Boolean>()
|
||||||
|
private val setTimelineMediaPreviewValueEvents = mutableListOf<MediaPreviewValue>()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun state(): MediaPreviewConfigState {
|
||||||
|
return MediaPreviewConfigState(
|
||||||
|
hideInviteAvatars = hideInviteAvatars.value,
|
||||||
|
timelineMediaPreviewValue = timelineMediaPreviewValue.value,
|
||||||
|
setHideInviteAvatarsAction = setHideInviteAvatarsAction.value,
|
||||||
|
setTimelineMediaPreviewAction = setTimelineMediaPreviewAction.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setHideInviteAvatars(hide: Boolean) {
|
||||||
|
setHideInviteAvatarsEvents.add(hide)
|
||||||
|
hideInviteAvatars.value = hide
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTimelineMediaPreviewValue(value: MediaPreviewValue) {
|
||||||
|
setTimelineMediaPreviewValueEvents.add(value)
|
||||||
|
timelineMediaPreviewValue.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSetHideInviteAvatarsEvents(): List<Boolean> = setHideInviteAvatarsEvents.toList()
|
||||||
|
fun getSetTimelineMediaPreviewValueEvents(): List<MediaPreviewValue> = setTimelineMediaPreviewValueEvents.toList()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionMode
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MediaPreviewConfigStateStoreTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state is correct with default values`() = runTest {
|
||||||
|
val store = createMediaPreviewConfigStateStore()
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
store.state()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.hideInviteAvatars).isFalse()
|
||||||
|
assertThat(initialState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
|
assertThat(initialState.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||||
|
assertThat(initialState.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `state updates when config flow emits new values`() = runTest {
|
||||||
|
val configFlow = MutableStateFlow(MediaPreviewConfig.DEFAULT)
|
||||||
|
val mediaPreviewService = FakeMediaPreviewService(configFlow)
|
||||||
|
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
store.state()
|
||||||
|
}.test {
|
||||||
|
// Initial state
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.hideInviteAvatars).isFalse()
|
||||||
|
assertThat(initialState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
configFlow.value = MediaPreviewConfig(hideInviteAvatar = true, mediaPreviewValue = MediaPreviewValue.Private)
|
||||||
|
|
||||||
|
skipItems(1)
|
||||||
|
// Updated state
|
||||||
|
val updatedState = awaitItem()
|
||||||
|
assertThat(updatedState.hideInviteAvatars).isTrue()
|
||||||
|
assertThat(updatedState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setHideInviteAvatars updates state and calls service on success`() = runTest {
|
||||||
|
val setHideInviteAvatarsValueLambda = lambdaRecorder<Boolean, Result<Unit>> { Result.success(Unit) }
|
||||||
|
val mediaPreviewService = FakeMediaPreviewService(
|
||||||
|
setHideInviteAvatarsResult = setHideInviteAvatarsValueLambda
|
||||||
|
)
|
||||||
|
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
store.state()
|
||||||
|
}.test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.hideInviteAvatars).isFalse()
|
||||||
|
}
|
||||||
|
store.setHideInviteAvatars(true)
|
||||||
|
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.hideInviteAvatars).isTrue()
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.hideInviteAvatars).isTrue()
|
||||||
|
assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.hideInviteAvatars).isTrue()
|
||||||
|
assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||||
|
}
|
||||||
|
assert(setHideInviteAvatarsValueLambda).isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setHideInviteAvatars reverts state on failure`() = runTest {
|
||||||
|
val setHideInviteAvatarsValueLambda = lambdaRecorder<Boolean, Result<Unit>> {
|
||||||
|
Result.failure(Exception())
|
||||||
|
}
|
||||||
|
val mediaPreviewService = FakeMediaPreviewService(
|
||||||
|
setHideInviteAvatarsResult = setHideInviteAvatarsValueLambda
|
||||||
|
)
|
||||||
|
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
store.state()
|
||||||
|
}.test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.hideInviteAvatars).isFalse()
|
||||||
|
}
|
||||||
|
store.setHideInviteAvatars(true)
|
||||||
|
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.hideInviteAvatars).isTrue()
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.hideInviteAvatars).isTrue()
|
||||||
|
assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
|
}
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.hideInviteAvatars).isFalse()
|
||||||
|
assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
|
}
|
||||||
|
assert(setHideInviteAvatarsValueLambda).isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setTimelineMediaPreviewValue updates state and calls service on success`() = runTest {
|
||||||
|
val setMediaPreviewValueLambda = lambdaRecorder<MediaPreviewValue, Result<Unit>> { Result.success(Unit) }
|
||||||
|
val mediaPreviewService = FakeMediaPreviewService(
|
||||||
|
setMediaPreviewValueResult = setMediaPreviewValueLambda
|
||||||
|
)
|
||||||
|
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
store.state()
|
||||||
|
}.test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
|
}
|
||||||
|
store.setTimelineMediaPreviewValue(MediaPreviewValue.Off)
|
||||||
|
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
|
||||||
|
assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
|
||||||
|
assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||||
|
}
|
||||||
|
assert(setMediaPreviewValueLambda).isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setTimelineMediaPreviewValue reverts state on failure`() = runTest {
|
||||||
|
val setMediaPreviewValueLambda = lambdaRecorder<MediaPreviewValue, Result<Unit>> {
|
||||||
|
Result.failure(Exception())
|
||||||
|
}
|
||||||
|
val mediaPreviewService = FakeMediaPreviewService(
|
||||||
|
setMediaPreviewValueResult = setMediaPreviewValueLambda
|
||||||
|
)
|
||||||
|
val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
store.state()
|
||||||
|
}.test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
|
}
|
||||||
|
store.setTimelineMediaPreviewValue(MediaPreviewValue.Off)
|
||||||
|
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
|
||||||
|
assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
|
}
|
||||||
|
skipItems(1)
|
||||||
|
awaitItem().also { state ->
|
||||||
|
assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
|
||||||
|
assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
|
}
|
||||||
|
assert(setMediaPreviewValueLambda).isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createMediaPreviewConfigStateStore(
|
||||||
|
mediaPreviewService: FakeMediaPreviewService = FakeMediaPreviewService(),
|
||||||
|
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher()
|
||||||
|
): MediaPreviewConfigStateStore = DefaultMediaPreviewConfigStateStore(
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
mediaPreviewService = mediaPreviewService,
|
||||||
|
snackbarDispatcher = snackbarDispatcher
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
|
|
@ -72,6 +73,7 @@ interface MatrixClient {
|
||||||
fun notificationSettingsService(): NotificationSettingsService
|
fun notificationSettingsService(): NotificationSettingsService
|
||||||
fun encryptionService(): EncryptionService
|
fun encryptionService(): EncryptionService
|
||||||
fun roomDirectoryService(): RoomDirectoryService
|
fun roomDirectoryService(): RoomDirectoryService
|
||||||
|
fun mediaPreviewService(): MediaPreviewService
|
||||||
suspend fun getCacheSize(): Long
|
suspend fun getCacheSize(): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.media
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for media preview ie. invite avatars and timeline media.
|
||||||
|
*/
|
||||||
|
data class MediaPreviewConfig(
|
||||||
|
val mediaPreviewValue: MediaPreviewValue,
|
||||||
|
val hideInviteAvatar: Boolean,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The default config if unknown (no local nor server config).
|
||||||
|
*/
|
||||||
|
val DEFAULT = MediaPreviewConfig(
|
||||||
|
mediaPreviewValue = MediaPreviewValue.On,
|
||||||
|
hideInviteAvatar = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.media
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface MediaPreviewService {
|
||||||
|
/**
|
||||||
|
* Will fetch the media preview config from the server.
|
||||||
|
*/
|
||||||
|
suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will emit the media preview config known by the client.
|
||||||
|
* This will emit a new value when received from sync.
|
||||||
|
*/
|
||||||
|
val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the media preview display policy. This will update the value on the server and update the local value when successful.
|
||||||
|
*/
|
||||||
|
suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the invite avatars display policy. This will update the value on the server and update the local value when successful.
|
||||||
|
*/
|
||||||
|
suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit>
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MediaPreviewService.getMediaPreviewValue() = mediaPreviewConfigFlow.value.mediaPreviewValue
|
||||||
|
|
@ -29,6 +29,7 @@ fun MediaPreviewValue.isPreviewEnabled(joinRule: JoinRule?): Boolean {
|
||||||
On -> true
|
On -> true
|
||||||
Off -> false
|
Off -> false
|
||||||
Private -> when (joinRule) {
|
Private -> when (joinRule) {
|
||||||
|
is JoinRule.Private,
|
||||||
is JoinRule.Knock,
|
is JoinRule.Knock,
|
||||||
is JoinRule.Invite,
|
is JoinRule.Invite,
|
||||||
is JoinRule.Restricted,
|
is JoinRule.Restricted,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||||
import io.element.android.libraries.matrix.api.createroom.RoomPreset
|
import io.element.android.libraries.matrix.api.createroom.RoomPreset
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
|
|
@ -52,6 +53,7 @@ import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
||||||
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
|
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
|
||||||
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
||||||
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
|
||||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
||||||
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
|
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
|
||||||
import io.element.android.libraries.matrix.impl.oidc.toRustAction
|
import io.element.android.libraries.matrix.impl.oidc.toRustAction
|
||||||
|
|
@ -214,6 +216,12 @@ class RustMatrixClient(
|
||||||
innerClient = innerClient,
|
innerClient = innerClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val mediaPreviewService = RustMediaPreviewService(
|
||||||
|
sessionCoroutineScope = sessionCoroutineScope,
|
||||||
|
innerClient = innerClient,
|
||||||
|
sessionDispatcher = sessionDispatcher,
|
||||||
|
)
|
||||||
|
|
||||||
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
||||||
|
|
||||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||||
|
|
@ -507,6 +515,8 @@ class RustMatrixClient(
|
||||||
|
|
||||||
override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
|
override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
|
||||||
|
|
||||||
|
override fun mediaPreviewService(): MediaPreviewService = mediaPreviewService
|
||||||
|
|
||||||
internal suspend fun destroy() {
|
internal suspend fun destroy() {
|
||||||
innerNotificationClient.close()
|
innerNotificationClient.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
|
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
|
||||||
|
|
@ -71,4 +72,9 @@ object SessionMatrixModule {
|
||||||
fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService {
|
fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService {
|
||||||
return matrixClient.roomDirectoryService()
|
return matrixClient.roomDirectoryService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun providesMediaPreviewService(matrixClient: MatrixClient): MediaPreviewService {
|
||||||
|
return matrixClient.mediaPreviewService()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.media
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
|
import org.matrix.rustcomponents.sdk.InviteAvatars
|
||||||
|
import org.matrix.rustcomponents.sdk.MediaPreviewConfigListener
|
||||||
|
import org.matrix.rustcomponents.sdk.MediaPreviews
|
||||||
|
import org.matrix.rustcomponents.sdk.MediaPreviewConfig as RustMediaPreviewConfig
|
||||||
|
|
||||||
|
class RustMediaPreviewService(
|
||||||
|
sessionCoroutineScope: CoroutineScope,
|
||||||
|
private val sessionDispatcher: CoroutineDispatcher,
|
||||||
|
private val innerClient: Client,
|
||||||
|
) : MediaPreviewService {
|
||||||
|
override val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig> =
|
||||||
|
innerClient
|
||||||
|
.getMediaPreviewConfigFlow()
|
||||||
|
.stateIn(sessionCoroutineScope, started = SharingStarted.Lazily, initialValue = MediaPreviewConfig.DEFAULT)
|
||||||
|
|
||||||
|
override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = withContext(sessionDispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
innerClient.fetchMediaPreviewConfig()?.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
val inviteAvatars = if (hide) InviteAvatars.OFF else InviteAvatars.ON
|
||||||
|
innerClient.setInviteAvatarsDisplayPolicy(inviteAvatars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RustMediaPreviewConfig.into(): MediaPreviewConfig {
|
||||||
|
return MediaPreviewConfig(
|
||||||
|
mediaPreviewValue = mediaPreviews.into(),
|
||||||
|
hideInviteAvatar = inviteAvatars == InviteAvatars.OFF
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow {
|
||||||
|
subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener {
|
||||||
|
override fun onChange(mediaPreviewConfig: RustMediaPreviewConfig?) {
|
||||||
|
if (mediaPreviewConfig != null) {
|
||||||
|
trySend(mediaPreviewConfig.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaPreviewValue.into(): MediaPreviews {
|
||||||
|
return when (this) {
|
||||||
|
MediaPreviewValue.On -> MediaPreviews.ON
|
||||||
|
MediaPreviewValue.Off -> MediaPreviews.OFF
|
||||||
|
MediaPreviewValue.Private -> MediaPreviews.PRIVATE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaPreviews.into(): MediaPreviewValue {
|
||||||
|
return when (this) {
|
||||||
|
MediaPreviews.ON -> MediaPreviewValue.On
|
||||||
|
MediaPreviews.OFF -> MediaPreviewValue.Off
|
||||||
|
MediaPreviews.PRIVATE -> MediaPreviewValue.Private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
|
|
@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||||
|
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
|
||||||
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
||||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||||
import io.element.android.libraries.matrix.test.pushers.FakePushersService
|
import io.element.android.libraries.matrix.test.pushers.FakePushersService
|
||||||
|
|
@ -72,6 +74,7 @@ class FakeMatrixClient(
|
||||||
private val syncService: FakeSyncService = FakeSyncService(),
|
private val syncService: FakeSyncService = FakeSyncService(),
|
||||||
private val encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
private val encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||||
private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
|
private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
|
||||||
|
private val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||||
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
|
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
|
||||||
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
|
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
|
||||||
Result.success(
|
Result.success(
|
||||||
|
|
@ -234,6 +237,7 @@ class FakeMatrixClient(
|
||||||
override fun notificationService(): NotificationService = notificationService
|
override fun notificationService(): NotificationService = notificationService
|
||||||
override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService
|
override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService
|
||||||
override fun encryptionService(): EncryptionService = encryptionService
|
override fun encryptionService(): EncryptionService = encryptionService
|
||||||
|
override fun mediaPreviewService(): MediaPreviewService = mediaPreviewService
|
||||||
|
|
||||||
override fun roomMembershipObserver(): RoomMembershipObserver {
|
override fun roomMembershipObserver(): RoomMembershipObserver {
|
||||||
return RoomMembershipObserver()
|
return RoomMembershipObserver()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.test.media
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class FakeMediaPreviewService(
|
||||||
|
override val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig> = MutableStateFlow(MediaPreviewConfig.DEFAULT),
|
||||||
|
private val fetchMediaPreviewConfigResult: () -> Result<MediaPreviewConfig?> = { lambdaError() },
|
||||||
|
private val setMediaPreviewValueResult: (MediaPreviewValue) -> Result<Unit> = { lambdaError() },
|
||||||
|
private val setHideInviteAvatarsResult: (Boolean) -> Result<Unit> = { lambdaError() },
|
||||||
|
) : MediaPreviewService {
|
||||||
|
override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = simulateLongTask {
|
||||||
|
fetchMediaPreviewConfigResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = simulateLongTask {
|
||||||
|
setMediaPreviewValueResult(mediaPreviewValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit> = simulateLongTask {
|
||||||
|
setHideInviteAvatarsResult(hide)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,11 +22,14 @@ interface AppPreferencesStore {
|
||||||
suspend fun setTheme(theme: String)
|
suspend fun setTheme(theme: String)
|
||||||
fun getThemeFlow(): Flow<String?>
|
fun getThemeFlow(): Flow<String?>
|
||||||
|
|
||||||
suspend fun setHideInviteAvatars(value: Boolean)
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
fun getHideInviteAvatarsFlow(): Flow<Boolean>
|
suspend fun setHideInviteAvatars(hide: Boolean?)
|
||||||
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue)
|
fun getHideInviteAvatarsFlow(): Flow<Boolean?>
|
||||||
fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue>
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
|
suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?)
|
||||||
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
|
fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?>
|
||||||
|
|
||||||
suspend fun setTracingLogLevel(logLevel: LogLevel)
|
suspend fun setTracingLogLevel(logLevel: LogLevel)
|
||||||
fun getTracingLogLevelFlow(): Flow<LogLevel>
|
fun getTracingLogLevelFlow(): Flow<LogLevel>
|
||||||
|
|
|
||||||
|
|
@ -85,27 +85,39 @@ class DefaultAppPreferencesStore @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setHideInviteAvatars(value: Boolean) {
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
store.edit { prefs ->
|
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
|
||||||
prefs[hideInviteAvatarsKey] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getHideInviteAvatarsFlow(): Flow<Boolean> {
|
|
||||||
return store.data.map { prefs ->
|
return store.data.map { prefs ->
|
||||||
prefs[hideInviteAvatarsKey] == true
|
prefs[hideInviteAvatarsKey]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) {
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
|
override suspend fun setHideInviteAvatars(hide: Boolean?) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
prefs[timelineMediaPreviewValueKey] = value.name
|
if (hide != null) {
|
||||||
|
prefs[hideInviteAvatarsKey] = hide
|
||||||
|
} else {
|
||||||
|
prefs.remove(hideInviteAvatarsKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue> {
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
|
override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (mediaPreviewValue != null) {
|
||||||
|
prefs[timelineMediaPreviewValueKey] = mediaPreviewValue.name
|
||||||
|
} else {
|
||||||
|
prefs.remove(timelineMediaPreviewValueKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
|
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?> {
|
||||||
return store.data.map { prefs ->
|
return store.data.map { prefs ->
|
||||||
prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } ?: MediaPreviewValue.On
|
prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
class InMemoryAppPreferencesStore(
|
class InMemoryAppPreferencesStore(
|
||||||
isDeveloperModeEnabled: Boolean = false,
|
isDeveloperModeEnabled: Boolean = false,
|
||||||
customElementCallBaseUrl: String? = null,
|
customElementCallBaseUrl: String? = null,
|
||||||
hideInviteAvatars: Boolean = false,
|
hideInviteAvatars: Boolean? = null,
|
||||||
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
|
timelineMediaPreviewValue: MediaPreviewValue? = null,
|
||||||
theme: String? = null,
|
theme: String? = null,
|
||||||
logLevel: LogLevel = LogLevel.INFO,
|
logLevel: LogLevel = LogLevel.INFO,
|
||||||
traceLockPacks: Set<TraceLogPack> = emptySet(),
|
traceLockPacks: Set<TraceLogPack> = emptySet(),
|
||||||
|
|
@ -55,20 +55,24 @@ class InMemoryAppPreferencesStore(
|
||||||
return theme
|
return theme
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setHideInviteAvatars(value: Boolean) {
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
hideInviteAvatars.value = value
|
override fun getHideInviteAvatarsFlow(): Flow<Boolean?> {
|
||||||
}
|
|
||||||
|
|
||||||
override fun getHideInviteAvatarsFlow(): Flow<Boolean> {
|
|
||||||
return hideInviteAvatars
|
return hideInviteAvatars
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTimelineMediaPreviewValue(value: MediaPreviewValue) {
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
timelineMediaPreviewValue.value = value
|
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue?> {
|
||||||
|
return timelineMediaPreviewValue
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTimelineMediaPreviewValueFlow(): Flow<MediaPreviewValue> {
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
return timelineMediaPreviewValue
|
override suspend fun setHideInviteAvatars(hide: Boolean?) {
|
||||||
|
hideInviteAvatars.value = hide
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use MediaPreviewService instead. Kept only for migration.")
|
||||||
|
override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) {
|
||||||
|
timelineMediaPreviewValue.value = mediaPreviewValue
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTracingLogLevel(logLevel: LogLevel) {
|
override suspend fun setTracingLogLevel(logLevel: LogLevel) {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
|
||||||
|
import io.element.android.libraries.matrix.api.media.getMediaPreviewValue
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
|
|
@ -41,7 +42,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|
||||||
import io.element.android.libraries.push.impl.R
|
import io.element.android.libraries.push.impl.R
|
||||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
|
|
@ -50,7 +50,6 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -79,7 +78,6 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
private val callNotificationEventResolver: CallNotificationEventResolver,
|
private val callNotificationEventResolver: CallNotificationEventResolver,
|
||||||
private val appPreferencesStore: AppPreferencesStore,
|
|
||||||
) : NotifiableEventResolver {
|
) : NotifiableEventResolver {
|
||||||
override suspend fun resolveEvents(
|
override suspend fun resolveEvents(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
|
|
@ -117,6 +115,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
||||||
): Result<ResolvedPushEvent> = runCatchingExceptions {
|
): Result<ResolvedPushEvent> = runCatchingExceptions {
|
||||||
when (val content = this.content) {
|
when (val content = this.content) {
|
||||||
is NotificationContent.MessageLike.RoomMessage -> {
|
is NotificationContent.MessageLike.RoomMessage -> {
|
||||||
|
val showMediaPreview = client.mediaPreviewService().getMediaPreviewValue() == MediaPreviewValue.On
|
||||||
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
|
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
|
||||||
val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName)
|
val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName)
|
||||||
val notifiableMessageEvent = buildNotifiableMessageEvent(
|
val notifiableMessageEvent = buildNotifiableMessageEvent(
|
||||||
|
|
@ -129,8 +128,8 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
||||||
timestamp = this.timestamp,
|
timestamp = this.timestamp,
|
||||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
||||||
body = messageBody,
|
body = messageBody,
|
||||||
imageUriString = content.fetchImageIfPresent(client)?.toString(),
|
imageUriString = if (showMediaPreview) content.fetchImageIfPresent(client)?.toString() else null,
|
||||||
imageMimeType = content.getImageMimetype(),
|
imageMimeType = if (showMediaPreview) content.getImageMimetype() else null,
|
||||||
roomName = roomDisplayName,
|
roomName = roomDisplayName,
|
||||||
roomIsDm = isDm,
|
roomIsDm = isDm,
|
||||||
roomAvatarPath = roomAvatarUrl,
|
roomAvatarPath = roomAvatarUrl,
|
||||||
|
|
@ -323,9 +322,6 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? {
|
private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent(client: MatrixClient): Uri? {
|
||||||
if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val fileResult = when (val messageType = messageType) {
|
val fileResult = when (val messageType = messageType) {
|
||||||
is ImageMessageType -> notificationMediaRepoFactory.create(client)
|
is ImageMessageType -> notificationMediaRepoFactory.create(client)
|
||||||
.getMediaFile(
|
.getMediaFile(
|
||||||
|
|
@ -349,10 +345,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? {
|
private fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? {
|
||||||
if (appPreferencesStore.getTimelineMediaPreviewValueFlow().first() != MediaPreviewValue.On) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return when (val messageType = messageType) {
|
return when (val messageType = messageType) {
|
||||||
is ImageMessageType -> messageType.info?.mimetype
|
is ImageMessageType -> messageType.info?.mimetype
|
||||||
is VideoMessageType -> null // Use the thumbnail here?
|
is VideoMessageType -> null // Use the thumbnail here?
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
||||||
import io.element.android.libraries.matrix.test.notification.aNotificationData
|
import io.element.android.libraries.matrix.test.notification.aNotificationData
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|
||||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
|
||||||
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
|
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
|
||||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||||
|
|
@ -826,7 +824,6 @@ class DefaultNotifiableEventResolverTest {
|
||||||
private fun createDefaultNotifiableEventResolver(
|
private fun createDefaultNotifiableEventResolver(
|
||||||
notificationService: FakeNotificationService? = FakeNotificationService(),
|
notificationService: FakeNotificationService? = FakeNotificationService(),
|
||||||
notificationResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap()),
|
notificationResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap()),
|
||||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
|
||||||
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
|
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
|
||||||
): DefaultNotifiableEventResolver {
|
): DefaultNotifiableEventResolver {
|
||||||
val context = RuntimeEnvironment.getApplication() as Context
|
val context = RuntimeEnvironment.getApplication() as Context
|
||||||
|
|
@ -849,7 +846,6 @@ class DefaultNotifiableEventResolverTest {
|
||||||
context = context,
|
context = context,
|
||||||
permalinkParser = FakePermalinkParser(),
|
permalinkParser = FakePermalinkParser(),
|
||||||
callNotificationEventResolver = callNotificationEventResolver,
|
callNotificationEventResolver = callNotificationEventResolver,
|
||||||
appPreferencesStore = appPreferencesStore,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e639d6e0f93e15f3ea39a0e0d9561a8da485abcc97a92b7370d33f2b701c40bb
|
||||||
|
size 46602
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:264e12f555c641960f0fef0c1177715fa5d596f7a42e0796d49533b68cfc88b9
|
||||||
|
size 46256
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9b797e153358ac8eb1e226ad537c23c61f4dd97ab8d5947b98bdd9b193f369b7
|
||||||
|
size 48487
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3853ed9a82dfa5bdf5f0e4d79e7af04bc7e43531e691c4d5fb17a37f5bd4f5b4
|
||||||
|
size 48249
|
||||||
Loading…
Add table
Add a link
Reference in a new issue