diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyPresenter.kt index 8e5d678f09..fcd2946f85 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyPresenter.kt @@ -10,7 +10,6 @@ package io.element.android.features.roomdetails.impl.securityandprivacy 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.derivedStateOf import androidx.compose.runtime.getValue @@ -22,6 +21,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.matchesServer +import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -35,8 +35,10 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import timber.log.Timber class SecurityAndPrivacyPresenter @AssistedInject constructor( @Assisted private val navigator: SecurityAndPrivacyNavigator, @@ -51,17 +53,22 @@ class SecurityAndPrivacyPresenter @AssistedInject constructor( @Composable override fun present(): SecurityAndPrivacyState { val coroutineScope = rememberCoroutineScope() + + val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val homeserverName = remember { matrixClient.userIdServerName() } - val roomInfo by room.roomInfoFlow.collectAsState(initial = null) - val isVisibleInRoomDirectory by isRoomVisibleInRoomDirectory() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val roomInfo by room.roomInfoFlow.collectAsState(null) + + val savedIsVisibleInRoomDirectory = remember { mutableStateOf>(AsyncData.Uninitialized) } + IsRoomVisibleInRoomDirectoryEffect(savedIsVisibleInRoomDirectory) val savedSettings by remember { derivedStateOf { SecurityAndPrivacySettings( roomAccess = roomInfo?.joinRule.map(), isEncrypted = room.isEncrypted, - isVisibleInRoomDirectory = isVisibleInRoomDirectory, - historyVisibility = roomInfo?.historyVisibility?.map(), + isVisibleInRoomDirectory = savedIsVisibleInRoomDirectory.value, + historyVisibility = roomInfo?.historyVisibility.map(), addressName = roomInfo?.firstDisplayableAlias(homeserverName)?.value ) } @@ -73,15 +80,12 @@ class SecurityAndPrivacyPresenter @AssistedInject constructor( var editedHistoryVisibility by remember(savedSettings.historyVisibility) { mutableStateOf(savedSettings.historyVisibility) } - var editedVisibleInRoomDirectory by remember(savedSettings.isVisibleInRoomDirectory) { - mutableStateOf(savedSettings.isVisibleInRoomDirectory) - } var editedIsEncrypted by remember(savedSettings.isEncrypted) { mutableStateOf(savedSettings.isEncrypted) } - - var showEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } - + var editedVisibleInRoomDirectory by remember(savedIsVisibleInRoomDirectory.value) { + mutableStateOf(savedIsVisibleInRoomDirectory.value) + } val editedSettings = SecurityAndPrivacySettings( roomAccess = editedRoomAccess, isEncrypted = editedIsEncrypted, @@ -90,18 +94,24 @@ class SecurityAndPrivacyPresenter @AssistedInject constructor( addressName = savedSettings.addressName, ) - val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + var showEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } + val permissions by room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value) fun handleEvents(event: SecurityAndPrivacyEvents) { when (event) { SecurityAndPrivacyEvents.Save -> { - coroutineScope.save(saveAction, savedSettings, editedSettings) + coroutineScope.save( + saveAction = saveAction, + isVisibleInRoomDirectory = savedIsVisibleInRoomDirectory, + savedSettings = savedSettings, + editedSettings = editedSettings + ) } is SecurityAndPrivacyEvents.ChangeRoomAccess -> { editedRoomAccess = event.roomAccess } is SecurityAndPrivacyEvents.ToggleEncryptionState -> { - if (editedSettings.isEncrypted) { + if (editedIsEncrypted) { editedIsEncrypted = false } else { showEncryptionConfirmation = true @@ -133,123 +143,137 @@ class SecurityAndPrivacyPresenter @AssistedInject constructor( homeserverName = homeserverName, showEncryptionConfirmation = showEncryptionConfirmation, saveAction = saveAction.value, + permissions = permissions, eventSink = ::handleEvents ) + + // If the history visibility is not available for the current access, use the fallback. LaunchedEffect(state.availableHistoryVisibilities) { - editedSettings.historyVisibility?.also { - if (it !in state.availableHistoryVisibilities) { - editedHistoryVisibility = it.fallback() - } + if (editedSettings.historyVisibility !in state.availableHistoryVisibilities) { + editedHistoryVisibility = editedSettings.historyVisibility.fallback() } } return state } @Composable - private fun isRoomVisibleInRoomDirectory(): State> { - val result = remember { mutableStateOf>(AsyncData.Uninitialized) } + private fun IsRoomVisibleInRoomDirectoryEffect(isRoomVisible: MutableState>) { LaunchedEffect(Unit) { - result.runUpdatingState { + isRoomVisible.runUpdatingState { room.getRoomVisibility().map { it == RoomVisibility.Public } } } - return result } private fun CoroutineScope.save( saveAction: MutableState>, + isVisibleInRoomDirectory: MutableState>, 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 - } + val enableEncryption = async { + if (editedSettings.isEncrypted && !savedSettings.isEncrypted) { + room.enableEncryption() + } else { + Result.success(Unit) + } } - if (editedSettings.historyVisibility != null && editedSettings.historyVisibility != savedSettings.historyVisibility) { - room - .updateHistoryVisibility(editedSettings.historyVisibility.map()) - .onFailure { - Timber.d("Failed to update history visibility") - somethingWentWrong = true - } + val updateHistoryVisibility = async { + if (editedSettings.historyVisibility != savedSettings.historyVisibility) { + room.updateHistoryVisibility(editedSettings.historyVisibility.map()) + } else { + Result.success(Unit) + } } - if (editedSettings.roomAccess != savedSettings.roomAccess) { - room - .updateJoinRule(editedSettings.roomAccess.map()) - .onFailure { - Timber.d("Failed to update join rule") - somethingWentWrong = true - } + val updateJoinRule = async { + if (editedSettings.roomAccess != savedSettings.roomAccess) { + room.updateJoinRule(editedSettings.roomAccess.map()) + } else { + Result.success(Unit) + } } - - val editedIsVisibleInRoomDirectory = when (editedSettings.roomAccess) { - SecurityAndPrivacyRoomAccess.AskToJoin, - SecurityAndPrivacyRoomAccess.Anyone -> editedSettings.isVisibleInRoomDirectory.dataOrNull() - else -> false + val updateRoomVisibility = async { + // When a user changes join rules to something other than knock or public, + // the room should be automatically made invisible (private) in the room directory. + 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) + .onSuccess { + isVisibleInRoomDirectory.value = AsyncData.Success(editedIsVisibleInRoomDirectory) + } + } else { + Result.success(Unit) + } } - 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 - } + val artificialDelay = async { + // Artificial delay to make sure the user sees the loading state + delay(500) + Result.success(Unit) } - if (somethingWentWrong) { - error("") + val results = awaitAll( + enableEncryption, + updateHistoryVisibility, + updateJoinRule, + updateRoomVisibility, + artificialDelay + ) + if (results.any { it.isFailure }) { + throw SecurityAndPrivacyFailures.SaveFailed } }.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.WorldReadable -> SecurityAndPrivacyHistoryVisibility.Anyone + RoomHistoryVisibility.Joined, + RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.SinceInvite + RoomHistoryVisibility.Shared, + is RoomHistoryVisibility.Custom, + null -> SecurityAndPrivacyHistoryVisibility.SinceSelection + + } +} + +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() +} + diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyState.kt index 3209c71b52..6d3c835d9a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyState.kt @@ -7,6 +7,7 @@ package io.element.android.features.roomdetails.impl.securityandprivacy +import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.SecurityAndPrivacyPermissions import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData @@ -18,12 +19,12 @@ data class SecurityAndPrivacyState( val homeserverName: String, val showEncryptionConfirmation: Boolean, val saveAction: AsyncAction, + private val permissions: SecurityAndPrivacyPermissions, val eventSink: (SecurityAndPrivacyEvents) -> Unit ) { val canBeSaved = savedSettings != editedSettings - val availableHistoryVisibilities = buildSet { add(SecurityAndPrivacyHistoryVisibility.SinceSelection) if (editedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone && !editedSettings.isEncrypted) { @@ -32,16 +33,17 @@ data class SecurityAndPrivacyState( add(SecurityAndPrivacyHistoryVisibility.SinceInvite) } } - val showRoomAccessSection: Boolean = true - val showRoomVisibilitySections = editedSettings.roomAccess != SecurityAndPrivacyRoomAccess.InviteOnly - val showHistoryVisibilitySection = editedSettings.historyVisibility != null - val showEncryptionSection = true + + val showRoomAccessSection = permissions.canChangeRoomAccess + val showRoomVisibilitySections = permissions.canChangeRoomVisibility && editedSettings.roomAccess != SecurityAndPrivacyRoomAccess.InviteOnly + val showHistoryVisibilitySection = permissions.canChangeHistoryVisibility + val showEncryptionSection = permissions.canChangeEncryption } data class SecurityAndPrivacySettings( val roomAccess: SecurityAndPrivacyRoomAccess, val isEncrypted: Boolean, - val historyVisibility: SecurityAndPrivacyHistoryVisibility?, + val historyVisibility: SecurityAndPrivacyHistoryVisibility, val addressName: String?, val isVisibleInRoomDirectory: AsyncData ) @@ -54,8 +56,8 @@ enum class SecurityAndPrivacyHistoryVisibility { */ fun fallback(): SecurityAndPrivacyHistoryVisibility { return when (this) { - SinceSelection -> SinceSelection - SinceInvite -> Anyone + SinceSelection, + SinceInvite -> SinceSelection Anyone -> SinceInvite } } @@ -64,3 +66,7 @@ enum class SecurityAndPrivacyHistoryVisibility { enum class SecurityAndPrivacyRoomAccess { InviteOnly, AskToJoin, Anyone, SpaceMember } + +sealed class SecurityAndPrivacyFailures : Exception() { + data object SaveFailed : SecurityAndPrivacyFailures() +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyStateProvider.kt index 8bf9b9757e..d09bcf617f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyStateProvider.kt @@ -8,6 +8,7 @@ package io.element.android.features.roomdetails.impl.securityandprivacy import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.SecurityAndPrivacyPermissions import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData @@ -27,7 +28,7 @@ open class SecurityAndPrivacyStateProvider : PreviewParameterProvider = AsyncData.Uninitialized, ) = SecurityAndPrivacySettings( roomAccess = roomAccess, @@ -67,6 +68,12 @@ fun aSecurityAndPrivacyState( homeserverName: String = "myserver.xyz", showEncryptionConfirmation: Boolean = false, saveAction: AsyncAction = AsyncAction.Uninitialized, + permissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions( + canChangeRoomAccess = true, + canChangeHistoryVisibility = true, + canChangeEncryption = true, + canChangeRoomVisibility = true + ), eventSink: (SecurityAndPrivacyEvents) -> Unit = {} ) = SecurityAndPrivacyState( editedSettings = editedSettings, @@ -74,5 +81,6 @@ fun aSecurityAndPrivacyState( homeserverName = homeserverName, showEncryptionConfirmation = showEncryptionConfirmation, saveAction = saveAction, + permissions = permissions, eventSink = eventSink ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyView.kt index 552d80186a..0b5054c363 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/securityandprivacy/SecurityAndPrivacyView.kt @@ -256,7 +256,6 @@ private fun RoomAddressSection( supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_address_section_footer)) }, onClick = onRoomAddressClick, colors = ListItemDefaults.colors(trailingIconColor = ElementTheme.colors.iconAccentPrimary), - alwaysClickable = true ) ListItem(