feat(security&privacy) : manage save action and some edge cases.

This commit is contained in:
ganfra 2025-01-23 14:19:23 +01:00
parent 9ee5927489
commit be199e25ff
5 changed files with 320 additions and 185 deletions

View file

@ -16,4 +16,5 @@ sealed interface SecurityAndPrivacyEvents {
data object ConfirmEnableEncryption: SecurityAndPrivacyEvents data object ConfirmEnableEncryption: SecurityAndPrivacyEvents
data class ChangeHistoryVisibility(val historyVisibility: SecurityAndPrivacyHistoryVisibility) : SecurityAndPrivacyEvents data class ChangeHistoryVisibility(val historyVisibility: SecurityAndPrivacyHistoryVisibility) : SecurityAndPrivacyEvents
data class ChangeRoomVisibility(val isVisibleInRoomDirectory: Boolean) : SecurityAndPrivacyEvents data class ChangeRoomVisibility(val isVisibleInRoomDirectory: Boolean) : SecurityAndPrivacyEvents
data object DismissSaveError : SecurityAndPrivacyEvents
} }

View file

@ -8,25 +8,35 @@
package io.element.android.features.roomdetails.impl.securityandprivacy package io.element.android.features.roomdetails.impl.securityandprivacy
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.matchesServer import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.matchesServer
import io.element.android.libraries.architecture.AsyncAction
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.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.join.JoinRule
import java.util.Optional import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
class SecurityAndPrivacyPresenter @AssistedInject constructor( class SecurityAndPrivacyPresenter @AssistedInject constructor(
@Assisted private val navigator: SecurityAndPrivacyNavigator, @Assisted private val navigator: SecurityAndPrivacyNavigator,
@ -40,67 +50,68 @@ class SecurityAndPrivacyPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): SecurityAndPrivacyState { override fun present(): SecurityAndPrivacyState {
val coroutineScope = rememberCoroutineScope()
val homeserverName = remember { matrixClient.userIdServerName() } val homeserverName = remember { matrixClient.userIdServerName() }
val roomInfo by room.roomInfoFlow.collectAsState(initial = null) val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
val isVisibleInRoomDirectory by isRoomVisibleInRoomDirectory()
val isVisibleInRoomDirectory = remember {
mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized)
}
val savedSettings by remember { val savedSettings by remember {
derivedStateOf { derivedStateOf {
SecurityAndPrivacySettings( SecurityAndPrivacySettings(
roomAccess = roomInfo?.joinRule.map(), roomAccess = roomInfo?.joinRule.map(),
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
isVisibleInRoomDirectory = Optional.ofNullable(isVisibleInRoomDirectory.value), isVisibleInRoomDirectory = isVisibleInRoomDirectory,
historyVisibility = Optional.ofNullable(roomInfo?.historyVisibility?.map()), historyVisibility = roomInfo?.historyVisibility?.map(),
addressName = Optional.ofNullable(roomInfo?.firstDisplayableAlias(homeserverName)?.value), addressName = roomInfo?.firstDisplayableAlias(homeserverName)?.value
) )
} }
} }
var currentRoomAccess by remember(savedSettings.roomAccess) { var editedRoomAccess by remember(savedSettings.roomAccess) {
mutableStateOf(savedSettings.roomAccess) mutableStateOf(savedSettings.roomAccess)
} }
var currentHistoryVisibility by remember(savedSettings.historyVisibility) { var editedHistoryVisibility by remember(savedSettings.historyVisibility) {
mutableStateOf(savedSettings.historyVisibility) mutableStateOf(savedSettings.historyVisibility)
} }
var currentVisibleInRoomDirectory by remember(savedSettings.isVisibleInRoomDirectory) { var editedVisibleInRoomDirectory by remember(savedSettings.isVisibleInRoomDirectory) {
mutableStateOf(savedSettings.isVisibleInRoomDirectory) mutableStateOf(savedSettings.isVisibleInRoomDirectory)
} }
var currentIsEncrypted by remember(savedSettings.isEncrypted) { var editedIsEncrypted by remember(savedSettings.isEncrypted) {
mutableStateOf(savedSettings.isEncrypted) mutableStateOf(savedSettings.isEncrypted)
} }
var showEncryptionConfirmation by remember { mutableStateOf(false) } var showEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) }
val currentSettings = SecurityAndPrivacySettings( val editedSettings = SecurityAndPrivacySettings(
roomAccess = currentRoomAccess, roomAccess = editedRoomAccess,
isEncrypted = currentIsEncrypted, isEncrypted = editedIsEncrypted,
isVisibleInRoomDirectory = currentVisibleInRoomDirectory, isVisibleInRoomDirectory = editedVisibleInRoomDirectory,
historyVisibility = currentHistoryVisibility, historyVisibility = editedHistoryVisibility,
addressName = savedSettings.addressName, addressName = savedSettings.addressName,
) )
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvents(event: SecurityAndPrivacyEvents) { fun handleEvents(event: SecurityAndPrivacyEvents) {
when (event) { when (event) {
SecurityAndPrivacyEvents.Save -> { SecurityAndPrivacyEvents.Save -> {
coroutineScope.save(saveAction, savedSettings, editedSettings)
} }
is SecurityAndPrivacyEvents.ChangeRoomAccess -> { is SecurityAndPrivacyEvents.ChangeRoomAccess -> {
currentRoomAccess = event.roomAccess editedRoomAccess = event.roomAccess
} }
is SecurityAndPrivacyEvents.ToggleEncryptionState -> { is SecurityAndPrivacyEvents.ToggleEncryptionState -> {
if(currentSettings.isEncrypted) { if (editedSettings.isEncrypted) {
currentIsEncrypted = false editedIsEncrypted = false
} else { } else {
showEncryptionConfirmation = true showEncryptionConfirmation = true
} }
} }
is SecurityAndPrivacyEvents.ChangeHistoryVisibility -> { is SecurityAndPrivacyEvents.ChangeHistoryVisibility -> {
currentHistoryVisibility = Optional.of(event.historyVisibility) editedHistoryVisibility = event.historyVisibility
} }
is SecurityAndPrivacyEvents.ChangeRoomVisibility -> { is SecurityAndPrivacyEvents.ChangeRoomVisibility -> {
currentVisibleInRoomDirectory = Optional.of(AsyncData.Success(event.isVisibleInRoomDirectory)) editedVisibleInRoomDirectory = AsyncData.Success(event.isVisibleInRoomDirectory)
} }
SecurityAndPrivacyEvents.EditRoomAddress -> navigator.openEditRoomAddress() SecurityAndPrivacyEvents.EditRoomAddress -> navigator.openEditRoomAddress()
SecurityAndPrivacyEvents.CancelEnableEncryption -> { SecurityAndPrivacyEvents.CancelEnableEncryption -> {
@ -108,60 +119,137 @@ class SecurityAndPrivacyPresenter @AssistedInject constructor(
} }
SecurityAndPrivacyEvents.ConfirmEnableEncryption -> { SecurityAndPrivacyEvents.ConfirmEnableEncryption -> {
showEncryptionConfirmation = false showEncryptionConfirmation = false
currentIsEncrypted = true editedIsEncrypted = true
}
SecurityAndPrivacyEvents.DismissSaveError -> {
saveAction.value = AsyncAction.Uninitialized
} }
} }
} }
return SecurityAndPrivacyState(
val state = SecurityAndPrivacyState(
savedSettings = savedSettings, savedSettings = savedSettings,
currentSettings = currentSettings, editedSettings = editedSettings,
homeserverName = homeserverName, homeserverName = homeserverName,
showEncryptionConfirmation = showEncryptionConfirmation, showEncryptionConfirmation = showEncryptionConfirmation,
saveAction = saveAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
LaunchedEffect(state.availableHistoryVisibilities) {
editedSettings.historyVisibility?.also {
if (it !in state.availableHistoryVisibilities) {
editedHistoryVisibility = it.fallback()
}
}
}
return state
}
@Composable
private fun isRoomVisibleInRoomDirectory(): State<AsyncData<Boolean>> {
val result = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
result.runUpdatingState {
room.getRoomVisibility().map { it == RoomVisibility.Public }
}
}
return result
}
private fun CoroutineScope.save(
saveAction: MutableState<AsyncAction<Unit>>,
savedSettings: SecurityAndPrivacySettings,
editedSettings: SecurityAndPrivacySettings,
) = launch {
suspend {
var somethingWentWrong = false
if (editedSettings.isEncrypted && !savedSettings.isEncrypted) {
room
.enableEncryption()
.onFailure {
Timber.d("Failed to enable encryption")
somethingWentWrong = true
}
}
if (editedSettings.historyVisibility != null && editedSettings.historyVisibility != savedSettings.historyVisibility) {
room
.updateHistoryVisibility(editedSettings.historyVisibility.map())
.onFailure {
Timber.d("Failed to update history visibility")
somethingWentWrong = true
}
}
if (editedSettings.roomAccess != savedSettings.roomAccess) {
room
.updateJoinRule(editedSettings.roomAccess.map())
.onFailure {
Timber.d("Failed to update join rule")
somethingWentWrong = true
}
}
val editedIsVisibleInRoomDirectory = when (editedSettings.roomAccess) {
SecurityAndPrivacyRoomAccess.AskToJoin,
SecurityAndPrivacyRoomAccess.Anyone -> editedSettings.isVisibleInRoomDirectory.dataOrNull()
else -> false
}
val savedIsVisibleInRoomDirectory = savedSettings.isVisibleInRoomDirectory.dataOrNull()
if (editedIsVisibleInRoomDirectory != null && editedIsVisibleInRoomDirectory != savedIsVisibleInRoomDirectory) {
val roomVisibility = if (editedIsVisibleInRoomDirectory) RoomVisibility.Public else RoomVisibility.Private
room
.updateRoomVisibility(roomVisibility)
.onFailure {
Timber.d("Failed to update room visibility")
somethingWentWrong = true
}
}
if (somethingWentWrong) {
error("")
}
}.runCatchingUpdatingState(saveAction)
}
private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess {
return when (this) {
JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone
JoinRule.Knock, is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoin
is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember
is JoinRule.Custom,
JoinRule.Invite,
JoinRule.Private,
null -> SecurityAndPrivacyRoomAccess.InviteOnly
}
}
private fun SecurityAndPrivacyRoomAccess.map(): JoinRule {
return when (this) {
SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public
SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock
SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private
SecurityAndPrivacyRoomAccess.SpaceMember -> error("Unsupported")
}
}
private fun RoomHistoryVisibility.map(): SecurityAndPrivacyHistoryVisibility {
return when (this) {
RoomHistoryVisibility.Joined,
RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.SinceInvite
RoomHistoryVisibility.Shared,
is RoomHistoryVisibility.Custom -> SecurityAndPrivacyHistoryVisibility.SinceSelection
RoomHistoryVisibility.WorldReadable -> SecurityAndPrivacyHistoryVisibility.Anyone
}
}
private fun SecurityAndPrivacyHistoryVisibility.map(): RoomHistoryVisibility {
return when (this) {
SecurityAndPrivacyHistoryVisibility.SinceSelection -> RoomHistoryVisibility.Shared
SecurityAndPrivacyHistoryVisibility.SinceInvite -> RoomHistoryVisibility.Invited
SecurityAndPrivacyHistoryVisibility.Anyone -> RoomHistoryVisibility.WorldReadable
}
}
private fun MatrixRoomInfo.firstDisplayableAlias(serverName: String): RoomAlias? {
return aliases.firstOrNull { it.matchesServer(serverName) } ?: aliases.firstOrNull()
} }
} }
private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess {
return when (this) {
JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone
JoinRule.Knock, is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoin
is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember
is JoinRule.Custom,
JoinRule.Invite,
JoinRule.Private,
null -> SecurityAndPrivacyRoomAccess.InviteOnly
}
}
private fun SecurityAndPrivacyRoomAccess.map(): JoinRule {
return when (this) {
SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public
SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock
SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private
SecurityAndPrivacyRoomAccess.SpaceMember -> error("Unsupported")
}
}
private fun RoomHistoryVisibility.map(): SecurityAndPrivacyHistoryVisibility {
return when (this) {
RoomHistoryVisibility.Joined,
RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.SinceInvite
RoomHistoryVisibility.Shared,
is RoomHistoryVisibility.Custom -> SecurityAndPrivacyHistoryVisibility.SinceSelection
RoomHistoryVisibility.WorldReadable -> SecurityAndPrivacyHistoryVisibility.Anyone
}
}
private fun SecurityAndPrivacyHistoryVisibility.map(): RoomHistoryVisibility {
return when (this) {
SecurityAndPrivacyHistoryVisibility.SinceSelection -> RoomHistoryVisibility.Shared
SecurityAndPrivacyHistoryVisibility.SinceInvite -> RoomHistoryVisibility.Invited
SecurityAndPrivacyHistoryVisibility.Anyone -> RoomHistoryVisibility.WorldReadable
}
}
private fun MatrixRoomInfo.firstDisplayableAlias(serverName: String): RoomAlias? {
return aliases.firstOrNull { it.matchesServer(serverName)} ?: aliases.firstOrNull()
}

View file

@ -7,46 +7,58 @@
package io.element.android.features.roomdetails.impl.securityandprivacy package io.element.android.features.roomdetails.impl.securityandprivacy
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
data class SecurityAndPrivacyState( data class SecurityAndPrivacyState(
// the settings that are currently applied on the room.
val savedSettings: SecurityAndPrivacySettings, val savedSettings: SecurityAndPrivacySettings,
val currentSettings: SecurityAndPrivacySettings, // the settings the user wants to apply.
val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String, val homeserverName: String,
val showEncryptionConfirmation: Boolean, val showEncryptionConfirmation: Boolean,
val saveAction: AsyncAction<Unit>,
val eventSink: (SecurityAndPrivacyEvents) -> Unit val eventSink: (SecurityAndPrivacyEvents) -> Unit
) { ) {
val canBeSaved = savedSettings != currentSettings val canBeSaved = savedSettings != editedSettings
val showRoomVisibilitySections = currentSettings.roomAccess != SecurityAndPrivacyRoomAccess.InviteOnly && currentSettings.historyVisibility.isPresent
val availableHistoryVisibilities = buildSet { val availableHistoryVisibilities = buildSet {
add(SecurityAndPrivacyHistoryVisibility.SinceSelection) add(SecurityAndPrivacyHistoryVisibility.SinceSelection)
if (currentSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone && !currentSettings.isEncrypted) { if (editedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone && !editedSettings.isEncrypted) {
add(SecurityAndPrivacyHistoryVisibility.Anyone) add(SecurityAndPrivacyHistoryVisibility.Anyone)
} else { } else {
add(SecurityAndPrivacyHistoryVisibility.SinceInvite) add(SecurityAndPrivacyHistoryVisibility.SinceInvite)
} }
if (savedSettings.historyVisibility.getOrNull() == SecurityAndPrivacyHistoryVisibility.SinceInvite) {
add(SecurityAndPrivacyHistoryVisibility.SinceInvite)
}
} }
val showRoomHistoryVisibilitySection = availableHistoryVisibilities.isNotEmpty() && currentSettings.historyVisibility.isPresent val showRoomAccessSection: Boolean = true
val showRoomVisibilitySections = editedSettings.roomAccess != SecurityAndPrivacyRoomAccess.InviteOnly
val showHistoryVisibilitySection = editedSettings.historyVisibility != null
val showEncryptionSection = true
} }
data class SecurityAndPrivacySettings( data class SecurityAndPrivacySettings(
val roomAccess: SecurityAndPrivacyRoomAccess, val roomAccess: SecurityAndPrivacyRoomAccess,
val isEncrypted: Boolean, val isEncrypted: Boolean,
val historyVisibility: Optional<SecurityAndPrivacyHistoryVisibility>, val historyVisibility: SecurityAndPrivacyHistoryVisibility?,
val addressName: Optional<String>, val addressName: String?,
val isVisibleInRoomDirectory: Optional<AsyncData<Boolean>> val isVisibleInRoomDirectory: AsyncData<Boolean>
) )
enum class SecurityAndPrivacyHistoryVisibility { enum class SecurityAndPrivacyHistoryVisibility {
SinceSelection, SinceInvite, Anyone SinceSelection, SinceInvite, Anyone;
/**
* Returns the fallback visibility when the current visibility is not available.
*/
fun fallback(): SecurityAndPrivacyHistoryVisibility {
return when (this) {
SinceSelection -> SinceSelection
SinceInvite -> Anyone
Anyone -> SinceInvite
}
}
} }
enum class SecurityAndPrivacyRoomAccess { enum class SecurityAndPrivacyRoomAccess {

View file

@ -8,37 +8,37 @@
package io.element.android.features.roomdetails.impl.securityandprivacy package io.element.android.features.roomdetails.impl.securityandprivacy
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.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import java.util.Optional
open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> { open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> {
override val values: Sequence<SecurityAndPrivacyState> override val values: Sequence<SecurityAndPrivacyState>
get() = sequenceOf( get() = sequenceOf(
aSecurityAndPrivacyState(), aSecurityAndPrivacyState(),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings( editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.AskToJoin roomAccess = SecurityAndPrivacyRoomAccess.AskToJoin
) )
), ),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings( editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone, roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
isEncrypted = false, isEncrypted = false,
) )
), ),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings( editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember
) )
), ),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings( editedSettings = aSecurityAndPrivacySettings(
isVisibleInRoomDirectory = Optional.of(AsyncData.Loading()) isVisibleInRoomDirectory = AsyncData.Loading()
) )
), ),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings( editedSettings = aSecurityAndPrivacySettings(
isVisibleInRoomDirectory = Optional.of(AsyncData.Success(true)) isVisibleInRoomDirectory = AsyncData.Success(true)
) )
), ),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
@ -50,9 +50,9 @@ open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAn
fun aSecurityAndPrivacySettings( fun aSecurityAndPrivacySettings(
roomAccess: SecurityAndPrivacyRoomAccess = SecurityAndPrivacyRoomAccess.InviteOnly, roomAccess: SecurityAndPrivacyRoomAccess = SecurityAndPrivacyRoomAccess.InviteOnly,
isEncrypted: Boolean = true, isEncrypted: Boolean = true,
formattedAddress: Optional<String> = Optional.empty(), formattedAddress: String? = null,
historyVisibility: Optional<SecurityAndPrivacyHistoryVisibility> = Optional.of(SecurityAndPrivacyHistoryVisibility.SinceSelection), historyVisibility: SecurityAndPrivacyHistoryVisibility? = null,
isVisibleInRoomDirectory: Optional<AsyncData<Boolean>> = Optional.empty() isVisibleInRoomDirectory: AsyncData<Boolean> = AsyncData.Uninitialized,
) = SecurityAndPrivacySettings( ) = SecurityAndPrivacySettings(
roomAccess = roomAccess, roomAccess = roomAccess,
isEncrypted = isEncrypted, isEncrypted = isEncrypted,
@ -62,15 +62,17 @@ fun aSecurityAndPrivacySettings(
) )
fun aSecurityAndPrivacyState( fun aSecurityAndPrivacyState(
currentSettings: SecurityAndPrivacySettings = aSecurityAndPrivacySettings(), savedSettings: SecurityAndPrivacySettings = aSecurityAndPrivacySettings(),
savedSettings: SecurityAndPrivacySettings = currentSettings, editedSettings: SecurityAndPrivacySettings = savedSettings,
homeserverName: String = "myserver.xyz", homeserverName: String = "myserver.xyz",
showEncryptionConfirmation: Boolean = false, showEncryptionConfirmation: Boolean = false,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (SecurityAndPrivacyEvents) -> Unit = {} eventSink: (SecurityAndPrivacyEvents) -> Unit = {}
) = SecurityAndPrivacyState( ) = SecurityAndPrivacyState(
currentSettings = currentSettings, editedSettings = editedSettings,
savedSettings = savedSettings, savedSettings = savedSettings,
homeserverName = homeserverName, homeserverName = homeserverName,
showEncryptionConfirmation = showEncryptionConfirmation, showEncryptionConfirmation = showEncryptionConfirmation,
saveAction = saveAction,
eventSink = eventSink eventSink = eventSink
) )

View file

@ -32,6 +32,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
@ -47,8 +49,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
@Composable @Composable
fun SecurityAndPrivacyView( fun SecurityAndPrivacyView(
@ -76,40 +76,58 @@ fun SecurityAndPrivacyView(
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(32.dp), verticalArrangement = Arrangement.spacedBy(32.dp),
) { ) {
RoomAccessSection( if (state.showRoomAccessSection) {
modifier = Modifier.padding(top = 24.dp), RoomAccessSection(
selected = state.currentSettings.roomAccess, modifier = Modifier.padding(top = 24.dp),
onSelected = { state.eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(it)) }, edited = state.editedSettings.roomAccess,
) saved = state.savedSettings.roomAccess,
onSelected = { state.eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(it)) },
)
}
if (state.showRoomVisibilitySections) { if (state.showRoomVisibilitySections) {
RoomVisibilitySection(state.homeserverName) RoomVisibilitySection(state.homeserverName)
RoomAddressSection( RoomAddressSection(
roomAddress = state.currentSettings.addressName, roomAddress = state.editedSettings.addressName,
homeserverName = state.homeserverName, homeserverName = state.homeserverName,
onRoomAddressClick = { state.eventSink(SecurityAndPrivacyEvents.EditRoomAddress) }, onRoomAddressClick = { state.eventSink(SecurityAndPrivacyEvents.EditRoomAddress) },
isVisibleInPublicDirectory = state.currentSettings.isVisibleInRoomDirectory, isVisibleInRoomDirectory = state.editedSettings.isVisibleInRoomDirectory,
onVisibilityChange = { isVisible -> onVisibilityChange = { isVisible ->
state.eventSink(SecurityAndPrivacyEvents.ChangeRoomVisibility(isVisible)) state.eventSink(SecurityAndPrivacyEvents.ChangeRoomVisibility(isVisible))
}, },
) )
} }
EncryptionSection( if (state.showEncryptionSection) {
isRoomEncrypted = state.currentSettings.isEncrypted, EncryptionSection(
isSectionEnabled = !state.savedSettings.isEncrypted, isRoomEncrypted = state.editedSettings.isEncrypted,
onToggleEncryption = { state.eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) }, canToggleEncryption = !state.savedSettings.isEncrypted,
showConfirmation = state.showEncryptionConfirmation, onToggleEncryption = { state.eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) },
onDismissConfirmation = { state.eventSink(SecurityAndPrivacyEvents.CancelEnableEncryption) }, showConfirmation = state.showEncryptionConfirmation,
onConfirmEncryption = { state.eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) }, onDismissConfirmation = { state.eventSink(SecurityAndPrivacyEvents.CancelEnableEncryption) },
) onConfirmEncryption = { state.eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) },
if (state.showRoomHistoryVisibilitySection) { )
RoomHistorySection( }
selectedOption = state.currentSettings.historyVisibility.get(), if (state.showHistoryVisibilitySection) {
HistoryVisibilitySection(
editedOption = state.editedSettings.historyVisibility,
savedOptions = state.savedSettings.historyVisibility,
availableOptions = state.availableHistoryVisibilities, availableOptions = state.availableHistoryVisibilities,
onSelected = { state.eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(it)) }, onSelected = { state.eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(it)) },
) )
} }
} }
} }
AsyncActionView(
async = state.saveAction,
onSuccess = { },
onErrorDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissSaveError) },
errorMessage = { stringResource(CommonStrings.error_unknown) },
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
progressText = stringResource(CommonStrings.common_saving),
)
},
onRetry = { state.eventSink(SecurityAndPrivacyEvents.Save) },
)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -160,7 +178,8 @@ private fun SecurityAndPrivacySection(
@Composable @Composable
private fun RoomAccessSection( private fun RoomAccessSection(
selected: SecurityAndPrivacyRoomAccess, edited: SecurityAndPrivacyRoomAccess,
saved: SecurityAndPrivacyRoomAccess,
onSelected: (SecurityAndPrivacyRoomAccess) -> Unit, onSelected: (SecurityAndPrivacyRoomAccess) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -171,26 +190,27 @@ private fun RoomAccessSection(
ListItem( ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_invite_only_option_title)) }, headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_invite_only_option_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_invite_only_option_description)) }, supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_invite_only_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = selected == SecurityAndPrivacyRoomAccess.InviteOnly), trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.InviteOnly),
onClick = { onSelected(SecurityAndPrivacyRoomAccess.InviteOnly) }, onClick = { onSelected(SecurityAndPrivacyRoomAccess.InviteOnly) },
) )
ListItem( ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_ask_to_join_option_title)) }, headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_ask_to_join_option_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_ask_to_join_option_description)) }, supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_ask_to_join_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = selected == SecurityAndPrivacyRoomAccess.AskToJoin), trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin),
onClick = { onSelected(SecurityAndPrivacyRoomAccess.AskToJoin) }, onClick = { onSelected(SecurityAndPrivacyRoomAccess.AskToJoin) },
) )
ListItem( ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_anyone_option_title)) }, headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_anyone_option_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_anyone_option_description)) }, supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_anyone_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = selected == SecurityAndPrivacyRoomAccess.Anyone), trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone),
onClick = { onSelected(SecurityAndPrivacyRoomAccess.Anyone) }, onClick = { onSelected(SecurityAndPrivacyRoomAccess.Anyone) },
) )
if (selected == SecurityAndPrivacyRoomAccess.SpaceMember) { if (saved == SecurityAndPrivacyRoomAccess.SpaceMember) {
ListItem( ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_space_members_option_title)) }, headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_space_members_option_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_space_members_option_description)) }, supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_space_members_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = true, enabled = false), trailingContent = ListItemContent.RadioButton(selected = true, enabled = false),
enabled = false,
) )
} }
} }
@ -217,9 +237,9 @@ private fun RoomVisibilitySection(
@Composable @Composable
private fun RoomAddressSection( private fun RoomAddressSection(
roomAddress: Optional<String>, roomAddress: String?,
homeserverName: String, homeserverName: String,
isVisibleInPublicDirectory: Optional<AsyncData<Boolean>>, isVisibleInRoomDirectory: AsyncData<Boolean>,
onRoomAddressClick: () -> Unit, onRoomAddressClick: () -> Unit,
onVisibilityChange: (Boolean) -> Unit, onVisibilityChange: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -230,54 +250,53 @@ private fun RoomAddressSection(
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text(text = roomAddress.getOrNull() ?: stringResource(CommonStrings.screen_security_and_privacy_add_room_address_action)) Text(text = roomAddress ?: stringResource(CommonStrings.screen_security_and_privacy_add_room_address_action))
}, },
trailingContent = if (roomAddress.isEmpty) ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())) else null, trailingContent = if (roomAddress.isNullOrEmpty()) ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())) else null,
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_address_section_footer)) }, supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_address_section_footer)) },
onClick = onRoomAddressClick, onClick = onRoomAddressClick,
colors = ListItemDefaults.colors(trailingIconColor = ElementTheme.colors.iconAccentPrimary), colors = ListItemDefaults.colors(trailingIconColor = ElementTheme.colors.iconAccentPrimary),
alwaysClickable = true alwaysClickable = true
) )
if (isVisibleInPublicDirectory.isPresent) {
ListItem( ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_directory_visibility_toggle_title)) }, headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_directory_visibility_toggle_title)) },
supportingContent = { supportingContent = {
Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_directory_visibility_section_footer, homeserverName)) Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_directory_visibility_section_footer, homeserverName))
}, },
trailingContent = trailingContent =
when (val isVisible = isVisibleInPublicDirectory.get()) { when (isVisibleInRoomDirectory) {
is AsyncData.Uninitialized, is AsyncData.Loading -> { is AsyncData.Uninitialized, is AsyncData.Loading -> {
ListItemContent.Custom { ListItemContent.Custom {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier modifier = Modifier
.progressSemantics() .progressSemantics()
.size(20.dp), .size(20.dp),
strokeWidth = 2.dp strokeWidth = 2.dp
)
}
}
is AsyncData.Failure -> {
ListItemContent.Switch(
checked = false,
enabled = false,
)
}
is AsyncData.Success -> {
ListItemContent.Switch(
checked = isVisible.data,
onChange = onVisibilityChange
) )
} }
} }
) is AsyncData.Failure -> {
} ListItemContent.Switch(
checked = false,
enabled = false,
)
}
is AsyncData.Success -> {
ListItemContent.Switch(
checked = isVisibleInRoomDirectory.data,
onChange = onVisibilityChange,
)
}
}
)
} }
} }
@Composable @Composable
private fun EncryptionSection( private fun EncryptionSection(
isRoomEncrypted: Boolean, isRoomEncrypted: Boolean,
isSectionEnabled: Boolean, canToggleEncryption: Boolean,
showConfirmation: Boolean, showConfirmation: Boolean,
onToggleEncryption: () -> Unit, onToggleEncryption: () -> Unit,
onConfirmEncryption: () -> Unit, onConfirmEncryption: () -> Unit,
@ -293,10 +312,10 @@ private fun EncryptionSection(
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_encryption_section_footer)) }, supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_encryption_section_footer)) },
trailingContent = ListItemContent.Switch( trailingContent = ListItemContent.Switch(
checked = isRoomEncrypted, checked = isRoomEncrypted,
enabled = isSectionEnabled, enabled = canToggleEncryption,
onChange = { onToggleEncryption() }, onChange = { onToggleEncryption() },
), ),
onClick = onToggleEncryption, onClick = if (canToggleEncryption) onToggleEncryption else null
) )
} }
if (showConfirmation) { if (showConfirmation) {
@ -311,8 +330,9 @@ private fun EncryptionSection(
} }
@Composable @Composable
private fun RoomHistorySection( private fun HistoryVisibilitySection(
selectedOption: SecurityAndPrivacyHistoryVisibility, editedOption: SecurityAndPrivacyHistoryVisibility?,
savedOptions: SecurityAndPrivacyHistoryVisibility?,
availableOptions: Set<SecurityAndPrivacyHistoryVisibility>, availableOptions: Set<SecurityAndPrivacyHistoryVisibility>,
onSelected: (SecurityAndPrivacyHistoryVisibility) -> Unit, onSelected: (SecurityAndPrivacyHistoryVisibility) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -323,34 +343,46 @@ private fun RoomHistorySection(
) { ) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
for (availableOption in availableOptions) { for (availableOption in availableOptions) {
val isSelected = availableOption == selectedOption val isSelected = availableOption == editedOption
when (availableOption) { HistoryVisibilityItem(
SecurityAndPrivacyHistoryVisibility.SinceSelection -> { option = availableOption,
ListItem( isSelected = isSelected,
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_history_since_selecting_option_title)) }, onSelected = onSelected,
trailingContent = ListItemContent.RadioButton(selected = isSelected), )
onClick = { onSelected(availableOption) }, }
) if (savedOptions != null && !availableOptions.contains(savedOptions)) {
} HistoryVisibilityItem(
SecurityAndPrivacyHistoryVisibility.SinceInvite -> { option = savedOptions,
ListItem( isSelected = true,
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_history_since_invite_option_title)) }, isEnabled = false,
trailingContent = ListItemContent.RadioButton(selected = isSelected), onSelected = {},
onClick = { onSelected(availableOption) }, )
)
}
SecurityAndPrivacyHistoryVisibility.Anyone -> {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_history_anyone_option_title)) },
trailingContent = ListItemContent.RadioButton(selected = isSelected),
onClick = { onSelected(availableOption) },
)
}
}
} }
} }
} }
@Composable
private fun HistoryVisibilityItem(
option: SecurityAndPrivacyHistoryVisibility,
isSelected: Boolean,
onSelected: (SecurityAndPrivacyHistoryVisibility) -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
) {
val headlineText = when (option) {
SecurityAndPrivacyHistoryVisibility.SinceSelection -> stringResource(CommonStrings.screen_security_and_privacy_room_history_since_selecting_option_title)
SecurityAndPrivacyHistoryVisibility.SinceInvite -> stringResource(CommonStrings.screen_security_and_privacy_room_history_since_invite_option_title)
SecurityAndPrivacyHistoryVisibility.Anyone -> stringResource(CommonStrings.screen_security_and_privacy_room_history_anyone_option_title)
}
ListItem(
headlineContent = { Text(text = headlineText) },
trailingContent = ListItemContent.RadioButton(selected = isSelected, enabled = isEnabled),
onClick = { onSelected(option) },
enabled = isEnabled,
modifier = modifier,
)
}
@PreviewWithLargeHeight @PreviewWithLargeHeight
@Composable @Composable
internal fun SecurityAndPrivacyViewLightPreview(@PreviewParameter(SecurityAndPrivacyStateProvider::class) state: SecurityAndPrivacyState) = internal fun SecurityAndPrivacyViewLightPreview(@PreviewParameter(SecurityAndPrivacyStateProvider::class) state: SecurityAndPrivacyState) =