change(security and privacy) : extract code to a separate module

This commit is contained in:
ganfra 2025-11-18 21:19:08 +01:00
parent da57eaadf2
commit c6ba2f5d10
64 changed files with 1296 additions and 55 deletions

View file

@ -40,7 +40,7 @@ import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackWithOverlayBox
@ -84,6 +84,7 @@ class RoomDetailsFlowNode(
private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val rolesAndPermissionsEntryPoint: RolesAndPermissionsEntryPoint,
private val securityAndPrivacyEntryPoint: SecurityAndPrivacyEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -381,7 +382,7 @@ class RoomDetailsFlowNode(
knockRequestsListEntryPoint.createNode(this, buildContext)
}
NavTarget.SecurityAndPrivacy -> {
createNode<SecurityAndPrivacyFlowNode>(buildContext)
securityAndPrivacyEntryPoint.createNode(this, buildContext)
}
is NavTarget.VerifyUser -> {
val params = OutgoingVerificationEntryPoint.Params(

View file

@ -22,7 +22,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers

View file

@ -1,21 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
sealed interface SecurityAndPrivacyEvents {
data object EditRoomAddress : SecurityAndPrivacyEvents
data object Save : SecurityAndPrivacyEvents
data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvents
data object ToggleEncryptionState : SecurityAndPrivacyEvents
data object CancelEnableEncryption : SecurityAndPrivacyEvents
data object ConfirmEnableEncryption : SecurityAndPrivacyEvents
data class ChangeHistoryVisibility(val historyVisibility: SecurityAndPrivacyHistoryVisibility) : SecurityAndPrivacyEvents
data object ToggleRoomVisibility : SecurityAndPrivacyEvents
data object DismissSaveError : SecurityAndPrivacyEvents
}

View file

@ -1,66 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.EditRoomAddressNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@AssistedInject
class SecurityAndPrivacyFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<SecurityAndPrivacyFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.SecurityAndPrivacy,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object SecurityAndPrivacy : NavTarget
@Parcelize
data object EditRoomAddress : NavTarget
}
private val navigator = BackstackSecurityAndPrivacyNavigator(backstack)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.SecurityAndPrivacy -> {
createNode<SecurityAndPrivacyNode>(buildContext, plugins = listOf(navigator))
}
NavTarget.EditRoomAddress -> {
createNode<EditRoomAddressNode>(buildContext, plugins = listOf(navigator))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(modifier)
}
}

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
interface SecurityAndPrivacyNavigator : Plugin {
fun openEditRoomAddress()
fun closeEditRoomAddress()
}
class BackstackSecurityAndPrivacyNavigator(
private val backStack: BackStack<SecurityAndPrivacyFlowNode.NavTarget>
) : SecurityAndPrivacyNavigator {
override fun openEditRoomAddress() {
backStack.push(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress)
}
override fun closeEditRoomAddress() {
backStack.pop()
}
}

View file

@ -1,41 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
@AssistedInject
class SecurityAndPrivacyNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SecurityAndPrivacyPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<SecurityAndPrivacyNavigator>().first()
private val presenter = presenterFactory.create(navigator)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SecurityAndPrivacyView(
state = state,
onBackClick = this::navigateUp,
modifier = modifier
)
}
}

View file

@ -1,293 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.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
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
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.roomdirectory.RoomVisibility
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@AssistedInject
class SecurityAndPrivacyPresenter(
@Assisted private val navigator: SecurityAndPrivacyNavigator,
private val matrixClient: MatrixClient,
private val room: JoinedRoom,
private val featureFlagService: FeatureFlagService,
) : Presenter<SecurityAndPrivacyState> {
@AssistedFactory
interface Factory {
fun create(navigator: SecurityAndPrivacyNavigator): SecurityAndPrivacyPresenter
}
@Composable
override fun present(): SecurityAndPrivacyState {
val coroutineScope = rememberCoroutineScope()
val isKnockEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(false)
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val homeserverName = remember { matrixClient.userIdServerName() }
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomInfo by room.roomInfoFlow.collectAsState()
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory)
}
val savedSettings by remember {
derivedStateOf {
val historyVisibility = roomInfo.historyVisibility.map()
SecurityAndPrivacySettings(
roomAccess = roomInfo.joinRule.map(),
isEncrypted = roomInfo.isEncrypted == true,
isVisibleInRoomDirectory = savedIsVisibleInRoomDirectory.value,
historyVisibility = historyVisibility,
address = roomInfo.firstDisplayableAlias(homeserverName)?.value,
)
}
}
var editedRoomAccess by remember(savedSettings.roomAccess) {
mutableStateOf(savedSettings.roomAccess)
}
var editedHistoryVisibility by remember(savedSettings.historyVisibility) {
mutableStateOf(savedSettings.historyVisibility)
}
var editedIsEncrypted by remember(savedSettings.isEncrypted) {
mutableStateOf(savedSettings.isEncrypted)
}
var editedVisibleInRoomDirectory by remember(savedIsVisibleInRoomDirectory.value) {
mutableStateOf(savedIsVisibleInRoomDirectory.value)
}
val editedSettings = SecurityAndPrivacySettings(
roomAccess = editedRoomAccess,
isEncrypted = editedIsEncrypted,
isVisibleInRoomDirectory = editedVisibleInRoomDirectory,
historyVisibility = editedHistoryVisibility,
address = savedSettings.address,
)
var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) }
val permissions by room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value)
fun handleEvent(event: SecurityAndPrivacyEvents) {
when (event) {
SecurityAndPrivacyEvents.Save -> {
coroutineScope.save(
saveAction = saveAction,
isVisibleInRoomDirectory = savedIsVisibleInRoomDirectory,
savedSettings = savedSettings,
editedSettings = editedSettings
)
}
is SecurityAndPrivacyEvents.ChangeRoomAccess -> {
editedRoomAccess = event.roomAccess
}
is SecurityAndPrivacyEvents.ToggleEncryptionState -> {
if (editedIsEncrypted) {
editedIsEncrypted = false
} else {
showEnableEncryptionConfirmation = true
}
}
is SecurityAndPrivacyEvents.ChangeHistoryVisibility -> {
editedHistoryVisibility = event.historyVisibility
}
SecurityAndPrivacyEvents.ToggleRoomVisibility -> {
editedVisibleInRoomDirectory = when (val edited = editedVisibleInRoomDirectory) {
is AsyncData.Success -> AsyncData.Success(!edited.data)
else -> edited
}
}
SecurityAndPrivacyEvents.EditRoomAddress -> navigator.openEditRoomAddress()
SecurityAndPrivacyEvents.CancelEnableEncryption -> {
showEnableEncryptionConfirmation = false
}
SecurityAndPrivacyEvents.ConfirmEnableEncryption -> {
showEnableEncryptionConfirmation = false
editedIsEncrypted = true
}
SecurityAndPrivacyEvents.DismissSaveError -> {
saveAction.value = AsyncAction.Uninitialized
}
}
}
val state = SecurityAndPrivacyState(
savedSettings = savedSettings,
editedSettings = editedSettings,
homeserverName = homeserverName,
showEnableEncryptionConfirmation = showEnableEncryptionConfirmation,
isKnockEnabled = isKnockEnabled,
saveAction = saveAction.value,
permissions = permissions,
eventSink = ::handleEvent,
)
// If the history visibility is not available for the current access, use the fallback.
LaunchedEffect(state.availableHistoryVisibilities) {
if (editedSettings.historyVisibility !in state.availableHistoryVisibilities) {
editedHistoryVisibility = editedSettings.historyVisibility.fallback()
}
}
return state
}
private fun CoroutineScope.isRoomVisibleInRoomDirectory(isRoomVisible: MutableState<AsyncData<Boolean>>) = launch {
isRoomVisible.runUpdatingState {
room.getRoomVisibility().map { it == RoomVisibility.Public }
}
}
private fun CoroutineScope.save(
saveAction: MutableState<AsyncAction<Unit>>,
isVisibleInRoomDirectory: MutableState<AsyncData<Boolean>>,
savedSettings: SecurityAndPrivacySettings,
editedSettings: SecurityAndPrivacySettings,
) = launch {
suspend {
val enableEncryption = async {
if (editedSettings.isEncrypted && !savedSettings.isEncrypted) {
room.enableEncryption()
} else {
Result.success(Unit)
}
}
val updateHistoryVisibility = async {
if (editedSettings.historyVisibility != savedSettings.historyVisibility) {
room.updateHistoryVisibility(editedSettings.historyVisibility.map())
} else {
Result.success(Unit)
}
}
val updateJoinRule = async {
val joinRule = editedSettings.roomAccess.map()
if (editedSettings.roomAccess != savedSettings.roomAccess && joinRule != null) {
room.updateJoinRule(joinRule)
} else {
Result.success(Unit)
}
}
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 artificialDelay = async {
// Artificial delay to make sure the user sees the loading state
delay(500)
Result.success(Unit)
}
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
JoinRule.Invite -> SecurityAndPrivacyRoomAccess.InviteOnly
// All other cases are not supported so we default to InviteOnly
is JoinRule.Custom,
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
// SpaceMember can't be selected in the ui
SecurityAndPrivacyRoomAccess.SpaceMember -> null
}
}
private fun RoomHistoryVisibility?.map(): SecurityAndPrivacyHistoryVisibility {
return when (this) {
RoomHistoryVisibility.WorldReadable -> SecurityAndPrivacyHistoryVisibility.Anyone
RoomHistoryVisibility.Joined,
RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.SinceInvite
RoomHistoryVisibility.Shared -> SecurityAndPrivacyHistoryVisibility.SinceSelection
// All other cases are not supported so we default to SinceSelection
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 RoomInfo.firstDisplayableAlias(serverName: String): RoomAlias? {
return aliases.firstOrNull { it.matchesServer(serverName) } ?: aliases.firstOrNull()
}

View file

@ -1,79 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.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
import kotlinx.collections.immutable.toImmutableSet
data class SecurityAndPrivacyState(
// the settings that are currently applied on the room.
val savedSettings: SecurityAndPrivacySettings,
// the settings the user wants to apply.
val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String,
val showEnableEncryptionConfirmation: Boolean,
val isKnockEnabled: Boolean,
val saveAction: AsyncAction<Unit>,
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) {
add(SecurityAndPrivacyHistoryVisibility.Anyone)
} else {
add(SecurityAndPrivacyHistoryVisibility.SinceInvite)
}
}.toImmutableSet()
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 address: String?,
val isVisibleInRoomDirectory: AsyncData<Boolean>
)
enum class SecurityAndPrivacyHistoryVisibility {
SinceSelection,
SinceInvite,
Anyone;
/**
* Returns the fallback visibility when the current visibility is not available.
*/
fun fallback(): SecurityAndPrivacyHistoryVisibility {
return when (this) {
SinceSelection,
SinceInvite -> SinceSelection
Anyone -> SinceInvite
}
}
}
enum class SecurityAndPrivacyRoomAccess {
InviteOnly,
AskToJoin,
Anyone,
SpaceMember
}
sealed class SecurityAndPrivacyFailures : Exception() {
data object SaveFailed : SecurityAndPrivacyFailures()
}

View file

@ -1,105 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.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
open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> {
override val values: Sequence<SecurityAndPrivacyState>
get() = sequenceOf(
aSecurityAndPrivacyState(),
aSecurityAndPrivacyState(
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.AskToJoin
)
),
aSecurityAndPrivacyState(
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
isEncrypted = false,
)
),
aSecurityAndPrivacyState(
savedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember
),
isKnockEnabled = false,
),
aSecurityAndPrivacyState(
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
address = "#therapy:myserver.xyz"
)
),
aSecurityAndPrivacyState(
editedSettings = aSecurityAndPrivacySettings(
isVisibleInRoomDirectory = AsyncData.Loading()
)
),
aSecurityAndPrivacyState(
editedSettings = aSecurityAndPrivacySettings(
isVisibleInRoomDirectory = AsyncData.Success(true)
)
),
aSecurityAndPrivacyState(
showEncryptionConfirmation = true
),
aSecurityAndPrivacyState(
saveAction = AsyncAction.Loading
),
aSecurityAndPrivacyState(
savedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.AskToJoin
),
isKnockEnabled = false,
),
)
}
fun aSecurityAndPrivacySettings(
roomAccess: SecurityAndPrivacyRoomAccess = SecurityAndPrivacyRoomAccess.InviteOnly,
isEncrypted: Boolean = true,
address: String? = null,
historyVisibility: SecurityAndPrivacyHistoryVisibility = SecurityAndPrivacyHistoryVisibility.SinceSelection,
isVisibleInRoomDirectory: AsyncData<Boolean> = AsyncData.Uninitialized,
) = SecurityAndPrivacySettings(
roomAccess = roomAccess,
isEncrypted = isEncrypted,
address = address,
historyVisibility = historyVisibility,
isVisibleInRoomDirectory = isVisibleInRoomDirectory
)
fun aSecurityAndPrivacyState(
savedSettings: SecurityAndPrivacySettings = aSecurityAndPrivacySettings(),
editedSettings: SecurityAndPrivacySettings = savedSettings,
homeserverName: String = "myserver.xyz",
showEncryptionConfirmation: Boolean = false,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
permissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions(
canChangeRoomAccess = true,
canChangeHistoryVisibility = true,
canChangeEncryption = true,
canChangeRoomVisibility = true
),
isKnockEnabled: Boolean = true,
eventSink: (SecurityAndPrivacyEvents) -> Unit = {}
) = SecurityAndPrivacyState(
editedSettings = editedSettings,
savedSettings = savedSettings,
homeserverName = homeserverName,
showEnableEncryptionConfirmation = showEncryptionConfirmation,
saveAction = saveAction,
isKnockEnabled = isKnockEnabled,
permissions = permissions,
eventSink = eventSink
)

View file

@ -1,406 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncData
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.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
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.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableSet
@Composable
fun SecurityAndPrivacyView(
state: SecurityAndPrivacyState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
SecurityAndPrivacyToolbar(
isSaveActionEnabled = state.canBeSaved,
onBackClick = onBackClick,
onSaveClick = {
state.eventSink(SecurityAndPrivacyEvents.Save)
},
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
if (state.showRoomAccessSection) {
RoomAccessSection(
modifier = Modifier.padding(top = 24.dp),
edited = state.editedSettings.roomAccess,
saved = state.savedSettings.roomAccess,
isKnockEnabled = state.isKnockEnabled,
onSelectOption = { state.eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(it)) },
)
}
if (state.showRoomVisibilitySections) {
RoomVisibilitySection(state.homeserverName)
RoomAddressSection(
roomAddress = state.editedSettings.address,
homeserverName = state.homeserverName,
onRoomAddressClick = { state.eventSink(SecurityAndPrivacyEvents.EditRoomAddress) },
isVisibleInRoomDirectory = state.editedSettings.isVisibleInRoomDirectory,
onVisibilityChange = {
state.eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility)
},
)
}
if (state.showEncryptionSection) {
EncryptionSection(
isRoomEncrypted = state.editedSettings.isEncrypted,
// encryption can't be disabled once enabled
canToggleEncryption = !state.savedSettings.isEncrypted,
onToggleEncryption = { state.eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) },
showConfirmation = state.showEnableEncryptionConfirmation,
onDismissConfirmation = { state.eventSink(SecurityAndPrivacyEvents.CancelEnableEncryption) },
onConfirmEncryption = { state.eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) },
)
}
if (state.showHistoryVisibilitySection) {
HistoryVisibilitySection(
editedOption = state.editedSettings.historyVisibility,
savedOptions = state.savedSettings.historyVisibility,
availableOptions = state.availableHistoryVisibilities,
onSelectOption = { 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)
@Composable
private fun SecurityAndPrivacyToolbar(
isSaveActionEnabled: Boolean,
onBackClick: () -> Unit,
onSaveClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
titleStr = stringResource(R.string.screen_room_details_security_and_privacy_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
enabled = isSaveActionEnabled,
onClick = onSaveClick,
)
}
)
}
@Composable
private fun SecurityAndPrivacySection(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.selectableGroup()
) {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp),
)
content()
}
}
@Composable
private fun RoomAccessSection(
edited: SecurityAndPrivacyRoomAccess,
saved: SecurityAndPrivacyRoomAccess,
isKnockEnabled: Boolean,
onSelectOption: (SecurityAndPrivacyRoomAccess) -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(R.string.screen_security_and_privacy_room_access_section_header),
modifier = modifier,
) {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.InviteOnly),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) },
)
// Show Ask to join option in two cases:
// - the Knock FF is enabled
// - AskToJoin is the current saved value
if (saved == SecurityAndPrivacyRoomAccess.AskToJoin || isKnockEnabled) {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) },
enabled = isKnockEnabled,
)
}
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
)
// Show space member option, but disabled as we don't support this option for now.
if (saved == SecurityAndPrivacyRoomAccess.SpaceMember) {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = true, enabled = false),
enabled = false,
)
}
}
}
@Composable
private fun RoomVisibilitySection(
homeserverName: String,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(R.string.screen_security_and_privacy_room_visibility_section_header),
modifier = modifier,
) {
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.screen_security_and_privacy_room_visibility_section_footer, homeserverName),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
@Composable
private fun RoomAddressSection(
roomAddress: String?,
homeserverName: String,
isVisibleInRoomDirectory: AsyncData<Boolean>,
onRoomAddressClick: () -> Unit,
onVisibilityChange: () -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(R.string.screen_security_and_privacy_room_address_section_header),
modifier = modifier,
) {
ListItem(
headlineContent = {
Text(text = roomAddress ?: stringResource(R.string.screen_security_and_privacy_add_room_address_action))
},
trailingContent = if (roomAddress.isNullOrEmpty()) ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())) else null,
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_address_section_footer)) },
onClick = onRoomAddressClick,
colors = ListItemDefaults.colors(trailingIconColor = ElementTheme.colors.iconAccentPrimary),
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_directory_visibility_toggle_title)) },
supportingContent = {
Text(text = stringResource(R.string.screen_security_and_privacy_room_directory_visibility_section_footer, homeserverName))
},
onClick = if (isVisibleInRoomDirectory.isSuccess()) onVisibilityChange else null,
trailingContent = when (isVisibleInRoomDirectory) {
is AsyncData.Uninitialized, is AsyncData.Loading -> {
ListItemContent.Custom {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(20.dp),
strokeWidth = 2.dp
)
}
}
is AsyncData.Failure -> {
ListItemContent.Switch(
checked = false,
enabled = false,
)
}
is AsyncData.Success -> {
ListItemContent.Switch(
checked = isVisibleInRoomDirectory.data,
)
}
}
)
}
}
@Composable
private fun EncryptionSection(
isRoomEncrypted: Boolean,
canToggleEncryption: Boolean,
showConfirmation: Boolean,
onToggleEncryption: () -> Unit,
onConfirmEncryption: () -> Unit,
onDismissConfirmation: () -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(R.string.screen_security_and_privacy_encryption_section_header),
modifier = modifier,
) {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_encryption_toggle_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_encryption_section_footer)) },
trailingContent = ListItemContent.Switch(
checked = isRoomEncrypted,
enabled = canToggleEncryption,
),
onClick = if (canToggleEncryption) onToggleEncryption else null
)
}
if (showConfirmation) {
ConfirmationDialog(
title = stringResource(R.string.screen_security_and_privacy_enable_encryption_alert_title),
content = stringResource(R.string.screen_security_and_privacy_enable_encryption_alert_description),
submitText = stringResource(R.string.screen_security_and_privacy_enable_encryption_alert_confirm_button_title),
onSubmitClick = onConfirmEncryption,
onDismiss = onDismissConfirmation,
)
}
}
@Composable
private fun HistoryVisibilitySection(
editedOption: SecurityAndPrivacyHistoryVisibility?,
savedOptions: SecurityAndPrivacyHistoryVisibility?,
availableOptions: ImmutableSet<SecurityAndPrivacyHistoryVisibility>,
onSelectOption: (SecurityAndPrivacyHistoryVisibility) -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(R.string.screen_security_and_privacy_room_history_section_header),
modifier = modifier,
) {
for (availableOption in availableOptions) {
val isSelected = availableOption == editedOption
HistoryVisibilityItem(
option = availableOption,
isSelected = isSelected,
onSelectOption = onSelectOption,
)
}
// Also show the saved option if it's not in the available options, but disabled
if (savedOptions != null && !availableOptions.contains(savedOptions)) {
HistoryVisibilityItem(
option = savedOptions,
isSelected = true,
isEnabled = false,
onSelectOption = {},
)
}
}
}
@Composable
private fun HistoryVisibilityItem(
option: SecurityAndPrivacyHistoryVisibility,
isSelected: Boolean,
onSelectOption: (SecurityAndPrivacyHistoryVisibility) -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
) {
val headlineText = when (option) {
SecurityAndPrivacyHistoryVisibility.SinceSelection -> stringResource(R.string.screen_security_and_privacy_room_history_since_selecting_option_title)
SecurityAndPrivacyHistoryVisibility.SinceInvite -> stringResource(R.string.screen_security_and_privacy_room_history_since_invite_option_title)
SecurityAndPrivacyHistoryVisibility.Anyone -> stringResource(R.string.screen_security_and_privacy_room_history_anyone_option_title)
}
ListItem(
headlineContent = { Text(text = headlineText) },
trailingContent = ListItemContent.RadioButton(selected = isSelected, enabled = isEnabled),
onClick = { onSelectOption(option) },
enabled = isEnabled,
modifier = modifier,
)
}
@PreviewWithLargeHeight
@Composable
internal fun SecurityAndPrivacyViewLightPreview(@PreviewParameter(SecurityAndPrivacyStateProvider::class) state: SecurityAndPrivacyState) =
ElementPreviewLight { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun SecurityAndPrivacyViewDarkPreview(@PreviewParameter(SecurityAndPrivacyStateProvider::class) state: SecurityAndPrivacyState) =
ElementPreviewDark { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: SecurityAndPrivacyState) {
SecurityAndPrivacyView(
state = state,
onBackClick = {},
)
}

View file

@ -1,15 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
sealed interface EditRoomAddressEvents {
data object Save : EditRoomAddressEvents
data object DismissError : EditRoomAddressEvents
data class RoomAddressChanged(val roomAddress: String) : EditRoomAddressEvents
}

View file

@ -1,42 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyNavigator
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
@AssistedInject
class EditRoomAddressNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: EditRoomAddressPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<SecurityAndPrivacyNavigator>().first()
private val presenter = presenterFactory.create(navigator)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
EditRoomAddressView(
state = state,
onBackClick = ::navigateUp,
modifier = modifier
)
}
}

View file

@ -1,150 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyNavigator
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.roomAliasFromName
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@AssistedInject
class EditRoomAddressPresenter(
@Assisted private val navigator: SecurityAndPrivacyNavigator,
private val client: MatrixClient,
private val room: JoinedRoom,
private val roomAliasHelper: RoomAliasHelper,
) : Presenter<EditRoomAddressState> {
@AssistedFactory
interface Factory {
fun create(navigator: SecurityAndPrivacyNavigator): EditRoomAddressPresenter
}
@Composable
override fun present(): EditRoomAddressState {
val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState()
val homeserverName = remember { client.userIdServerName() }
val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
}
val savedRoomAddress by remember { derivedStateOf { roomInfo.firstAliasMatching(homeserverName)?.addressName() } }
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
var newRoomAddress by remember {
mutableStateOf(
savedRoomAddress ?: roomAliasHelper.roomAliasNameFromRoomDisplayName(roomInfo.name.orEmpty())
)
}
fun handleEvent(event: EditRoomAddressEvents) {
when (event) {
EditRoomAddressEvents.Save -> coroutineScope.save(
saveAction = saveAction,
serverName = homeserverName,
newRoomAddress = newRoomAddress
)
is EditRoomAddressEvents.RoomAddressChanged -> {
newRoomAddress = event.roomAddress
}
EditRoomAddressEvents.DismissError -> {
saveAction.value = AsyncAction.Uninitialized
}
}
}
RoomAddressValidityEffect(
client = client,
roomAliasHelper = roomAliasHelper,
newRoomAddress = newRoomAddress,
knownRoomAddress = savedRoomAddress
) { newRoomAddressValidity ->
roomAddressValidity.value = newRoomAddressValidity
}
return EditRoomAddressState(
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity.value,
roomAddress = newRoomAddress,
saveAction = saveAction.value,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.save(
saveAction: MutableState<AsyncAction<Unit>>,
serverName: String,
newRoomAddress: String,
) = launch {
suspend {
val roomInfo = room.info()
val savedCanonicalAlias = roomInfo.canonicalAlias
val savedAliasFromHomeserver = roomInfo.firstAliasMatching(serverName)
val newRoomAlias = client.roomAliasFromName(newRoomAddress) ?: throw IllegalArgumentException("Invalid room address")
// First publish the new alias in the room directory
room.publishRoomAliasInRoomDirectory(newRoomAlias).getOrThrow()
// Then try remove the old alias from the room directory
if (savedAliasFromHomeserver != null) {
room.removeRoomAliasFromRoomDirectory(savedAliasFromHomeserver).getOrThrow()
}
// Finally update the canonical alias state
when {
// Allow to update the canonical alias only if the saved canonical alias matches the homeserver or if there is no canonical alias
savedCanonicalAlias == null || savedCanonicalAlias.matchesServer(serverName) -> {
val newAlternativeAliases = roomInfo.alternativeAliases.filter { it != savedAliasFromHomeserver }
room.updateCanonicalAlias(newRoomAlias, newAlternativeAliases).getOrThrow()
}
// Otherwise, only update the alternative aliases and keep the current canonical alias
else -> {
val newAlternativeAliases = buildList {
// New alias is added first, so we make sure we pick it first
add(newRoomAlias)
// Add all other aliases, except the one we just removed from the room directory
addAll(roomInfo.alternativeAliases.filter { it != savedAliasFromHomeserver })
}
room.updateCanonicalAlias(savedCanonicalAlias, newAlternativeAliases).getOrThrow()
}
}
navigator.closeEditRoomAddress()
}.runCatchingUpdatingState(saveAction)
}
}
/**
* Returns the first alias that matches the given server name, or null if none match.
*/
private fun RoomInfo.firstAliasMatching(serverName: String): RoomAlias? {
// Check if the canonical alias matches the homeserver
if (canonicalAlias?.matchesServer(serverName) == true) {
return canonicalAlias
}
return alternativeAliases.firstOrNull { it.matchesServer(serverName) }
}

View file

@ -1,22 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
data class EditRoomAddressState(
val homeserverName: String,
val roomAddress: String,
val roomAddressValidity: RoomAddressValidity,
val saveAction: AsyncAction<Unit>,
val eventSink: (EditRoomAddressEvents) -> Unit
) {
val canBeSaved = roomAddressValidity == RoomAddressValidity.Valid
}

View file

@ -1,38 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
open class EditRoomAddressStateProvider : PreviewParameterProvider<EditRoomAddressState> {
override val values: Sequence<EditRoomAddressState>
get() = sequenceOf(
anEditRoomAddressState(),
anEditRoomAddressState(roomAddressValidity = RoomAddressValidity.NotAvailable),
anEditRoomAddressState(roomAddressValidity = RoomAddressValidity.InvalidSymbols),
anEditRoomAddressState(roomAddressValidity = RoomAddressValidity.Valid),
anEditRoomAddressState(roomAddressValidity = RoomAddressValidity.Valid, saveAction = AsyncAction.Loading),
)
}
fun anEditRoomAddressState(
roomAddress: String = "therapy",
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Unknown,
homeserverName: String = ":myserver.org",
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (EditRoomAddressEvents) -> Unit = {}
) = EditRoomAddressState(
roomAddress = roomAddress,
roomAddressValidity = roomAddressValidity,
homeserverName = homeserverName,
saveAction = saveAction,
eventSink = eventSink
)

View file

@ -1,121 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.impl.R
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.room.address.RoomAddressField
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun EditRoomAddressView(
state: EditRoomAddressState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
EditRoomAddressTopBar(
isSaveActionEnabled = state.canBeSaved,
onBackClick = onBackClick,
onSaveClick = {
state.eventSink(EditRoomAddressEvents.Save)
},
)
}
) { padding ->
Box(
modifier = Modifier
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding)
) {
RoomAddressField(
address = state.roomAddress,
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = {
state.eventSink(EditRoomAddressEvents.RoomAddressChanged(it))
},
label = stringResource(R.string.screen_edit_room_address_title),
supportingText = stringResource(R.string.screen_edit_room_address_room_address_section_footer),
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp)
)
}
AsyncActionView(
async = state.saveAction,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
progressText = stringResource(CommonStrings.common_saving),
)
},
onSuccess = {},
errorMessage = { stringResource(CommonStrings.error_unknown) },
onRetry = { state.eventSink(EditRoomAddressEvents.Save) },
onErrorDismiss = { state.eventSink(EditRoomAddressEvents.DismissError) },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditRoomAddressTopBar(
isSaveActionEnabled: Boolean,
onBackClick: () -> Unit,
onSaveClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
titleStr = stringResource(R.string.screen_edit_room_address_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
enabled = isSaveActionEnabled,
onClick = onSaveClick,
)
}
)
}
@PreviewsDayNight
@Composable
internal fun EditRoomAddressViewPreview(
@PreviewParameter(EditRoomAddressStateProvider::class) state: EditRoomAddressState
) = ElementPreview {
EditRoomAddressView(
state = state,
onBackClick = {},
)
}

View file

@ -1,25 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
import io.element.android.libraries.matrix.api.core.RoomAlias
/**
* Returns the local part of the alias.
*/
fun RoomAlias.addressName(): String {
return value.drop(1).split(":").first()
}
/**
* Checks if the room alias matches the given server name.
*/
fun RoomAlias.matchesServer(serverName: String): Boolean {
return value.split(":").last() == serverName
}

View file

@ -1,50 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.permissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.SecurityAndPrivacyPermissions.Companion.DEFAULT
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
data class SecurityAndPrivacyPermissions(
val canChangeRoomAccess: Boolean,
val canChangeHistoryVisibility: Boolean,
val canChangeEncryption: Boolean,
val canChangeRoomVisibility: Boolean,
) {
val hasAny = canChangeRoomAccess ||
canChangeHistoryVisibility ||
canChangeEncryption ||
canChangeRoomVisibility
companion object {
val DEFAULT = SecurityAndPrivacyPermissions(
canChangeRoomAccess = false,
canChangeHistoryVisibility = false,
canChangeEncryption = false,
canChangeRoomVisibility = false,
)
}
}
@Composable
fun BaseRoom.securityAndPrivacyPermissionsAsState(updateKey: Long): State<SecurityAndPrivacyPermissions> {
return produceState(DEFAULT, key1 = updateKey) {
value = SecurityAndPrivacyPermissions(
canChangeRoomAccess = canSendState(type = StateEventType.ROOM_JOIN_RULES).getOrElse { false },
canChangeHistoryVisibility = canSendState(type = StateEventType.ROOM_HISTORY_VISIBILITY).getOrElse { false },
canChangeEncryption = canSendState(type = StateEventType.ROOM_ENCRYPTION).getOrElse { false },
canChangeRoomVisibility = canSendState(type = StateEventType.ROOM_CANONICAL_ALIAS).getOrElse { false },
)
}
}

View file

@ -20,6 +20,7 @@ import io.element.android.features.messages.test.FakeMessagesEntryPoint
import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint
import io.element.android.features.reportroom.test.FakeReportRoomEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.securityandprivacy.test.FakeSecurityAndPrivacyEntryPoint
import io.element.android.features.verifysession.test.FakeOutgoingVerificationEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -61,6 +62,7 @@ class DefaultRoomDetailsEntryPointTest {
reportRoomEntryPoint = FakeReportRoomEntryPoint(),
changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(),
rolesAndPermissionsEntryPoint = FakeRolesAndPermissionsEntryPoint(),
securityAndPrivacyEntryPoint = FakeSecurityAndPrivacyEntryPoint(),
)
}
val callback = object : RoomDetailsEntryPoint.Callback {

View file

@ -1,24 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
import io.element.android.tests.testutils.lambda.lambdaError
class FakeSecurityAndPrivacyNavigator(
private val openEditRoomAddressLambda: () -> Unit = { lambdaError() },
private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() },
) : SecurityAndPrivacyNavigator {
override fun openEditRoomAddress() {
openEditRoomAddressLambda()
}
override fun closeEditRoomAddress() {
closeEditRoomAddressLambda()
}
}

View file

@ -1,386 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
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.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SecurityAndPrivacyPresenterTest {
@Test
fun `present - initial states`() = runTest {
val presenter = createSecurityAndPrivacyPresenter()
presenter.test {
with(awaitItem()) {
assertThat(editedSettings).isEqualTo(savedSettings)
assertThat(canBeSaved).isFalse()
assertThat(showEnableEncryptionConfirmation).isFalse()
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(showRoomAccessSection).isFalse()
assertThat(showRoomVisibilitySections).isFalse()
assertThat(showHistoryVisibilitySection).isFalse()
assertThat(showEncryptionSection).isFalse()
assertThat(isKnockEnabled).isFalse()
}
with(awaitItem()) {
assertThat(editedSettings).isEqualTo(savedSettings)
assertThat(canBeSaved).isFalse()
assertThat(showEnableEncryptionConfirmation).isFalse()
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(showRoomAccessSection).isTrue()
assertThat(showRoomVisibilitySections).isFalse()
assertThat(showHistoryVisibilitySection).isTrue()
assertThat(showEncryptionSection).isTrue()
assertThat(isKnockEnabled).isFalse()
}
}
}
@Test
fun `present - room info change updates saved and edited settings`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
initialRoomInfo = aRoomInfo(
joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable,
canonicalAlias = A_ROOM_ALIAS,
)
)
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings).isEqualTo(savedSettings)
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone)
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone)
assertThat(editedSettings.address).isEqualTo(A_ROOM_ALIAS.value)
assertThat(canBeSaved).isFalse()
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - change room access`() = runTest {
val presenter = createSecurityAndPrivacyPresenter()
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
assertThat(showRoomVisibilitySections).isFalse()
eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone))
}
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone)
assertThat(showRoomVisibilitySections).isTrue()
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly))
}
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
assertThat(showRoomVisibilitySections).isFalse()
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - change history visibility`() = runTest {
val presenter = createSecurityAndPrivacyPresenter()
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceSelection)
eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceInvite))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceInvite)
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceSelection))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceSelection)
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - enable encryption`() = runTest {
val presenter = createSecurityAndPrivacyPresenter()
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isFalse()
eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState)
}
with(awaitItem()) {
assertThat(showEnableEncryptionConfirmation).isTrue()
eventSink(SecurityAndPrivacyEvents.CancelEnableEncryption)
}
with(awaitItem()) {
assertThat(showEnableEncryptionConfirmation).isFalse()
eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState)
}
with(awaitItem()) {
assertThat(showEnableEncryptionConfirmation).isTrue()
eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption)
}
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isTrue()
assertThat(showEnableEncryptionConfirmation).isFalse()
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState)
}
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isFalse()
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - room visibility loading and change`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared)
)
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Loading<Boolean>())
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false))
eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true))
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false))
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - edit room address`() = runTest {
val openEditRoomAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda)
val presenter = createSecurityAndPrivacyPresenter(navigator = navigator)
presenter.test {
skipItems(1)
with(awaitItem()) {
eventSink(SecurityAndPrivacyEvents.EditRoomAddress)
}
assert(openEditRoomAddressLambda).isCalledOnce()
}
}
@Test
fun `present - save success`() = runTest {
val enableEncryptionLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val updateJoinRuleLambda = lambdaRecorder<JoinRule, Result<Unit>> { Result.success(Unit) }
val updateRoomVisibilityLambda = lambdaRecorder<RoomVisibility, Result<Unit>> { Result.success(Unit) }
val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite, historyVisibility = RoomHistoryVisibility.Shared)
),
enableEncryptionResult = enableEncryptionLambda,
updateJoinRuleResult = updateJoinRuleLambda,
updateRoomVisibilityResult = updateRoomVisibilityLambda,
updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda,
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(2)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone))
}
with(awaitItem()) {
eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Anyone))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone)
eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption)
}
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isTrue()
eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true))
eventSink(SecurityAndPrivacyEvents.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
room.givenRoomInfo(
aRoomInfo(
joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable,
isEncrypted = true,
)
)
// Saved settings are updated 3 times to match the edited settings
skipItems(3)
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(savedSettings).isEqualTo(editedSettings)
assertThat(canBeSaved).isFalse()
}
assert(enableEncryptionLambda).isCalledOnce()
assert(updateJoinRuleLambda).isCalledOnce()
assert(updateRoomVisibilityLambda).isCalledOnce()
assert(updateRoomHistoryVisibilityLambda).isCalledOnce()
}
}
@Test
fun `present - save failure`() = runTest {
val enableEncryptionLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val updateJoinRuleLambda = lambdaRecorder<JoinRule, Result<Unit>> { Result.success(Unit) }
val updateRoomVisibilityLambda = lambdaRecorder<RoomVisibility, Result<Unit>> {
Result.failure(Exception("Failed to update room visibility"))
}
val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private)
),
enableEncryptionResult = enableEncryptionLambda,
updateJoinRuleResult = updateJoinRuleLambda,
updateRoomVisibilityResult = updateRoomVisibilityLambda,
updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda,
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(2)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone))
}
with(awaitItem()) {
eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Anyone))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone)
eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption)
}
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isTrue()
eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true))
eventSink(SecurityAndPrivacyEvents.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
room.givenRoomInfo(
aRoomInfo(
joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable,
)
)
// Saved settings are updated 2 times to match the edited settings
skipItems(3)
val state = awaitItem()
with(state) {
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(savedSettings.isVisibleInRoomDirectory).isNotEqualTo(editedSettings.isVisibleInRoomDirectory)
assertThat(canBeSaved).isTrue()
}
assert(enableEncryptionLambda).isCalledOnce()
assert(updateJoinRuleLambda).isCalledOnce()
assert(updateRoomVisibilityLambda).isCalledOnce()
assert(updateRoomHistoryVisibilityLambda).isCalledOnce()
// Clear error
state.eventSink(SecurityAndPrivacyEvents.DismissSaveError)
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
@Test
fun `present - isKnockEnabled is true if the Knock feature flag is enabled`() = runTest {
val presenter = createSecurityAndPrivacyPresenter(
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.Knock.key to true,
)
)
)
presenter.test {
assertThat(awaitItem().isKnockEnabled).isFalse()
assertThat(awaitItem().isKnockEnabled).isTrue()
}
}
private fun createSecurityAndPrivacyPresenter(
serverName: String = "matrix.org",
room: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private)
),
),
navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter(
room = room,
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { serverName },
),
navigator = navigator,
featureFlagService = featureFlagService,
)
}
}

View file

@ -1,165 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class SecurityAndPrivacyViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `click on back invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setSecurityAndPrivacyView(
onBackClick = callback,
)
rule.pressBack()
}
}
@Test
fun `click on room access item emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_room_access_invite_only_option_title)
recorder.assertSingle(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly))
}
@Test
fun `click on disabled save doesn't emit event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>(expectEvents = false)
val state = aSecurityAndPrivacyState(eventSink = recorder)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(CommonStrings.action_save)
recorder.assertEmpty()
}
@Test
fun `click on enabled save emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
)
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(CommonStrings.action_save)
recorder.assertSingle(SecurityAndPrivacyEvents.Save)
}
@Test
@Config(qualifiers = "h640dp")
fun `click on room address item emits the expected event`() {
val address = "@alias:matrix.org"
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
editedSettings = aSecurityAndPrivacySettings(
address = address,
roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
),
)
rule.setSecurityAndPrivacyView(state)
rule.onNodeWithText(address).performClick()
recorder.assertSingle(SecurityAndPrivacyEvents.EditRoomAddress)
}
@Test
@Config(qualifiers = "h1024dp")
fun `click on room visibility item emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
isVisibleInRoomDirectory = AsyncData.Success(false),
),
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_room_directory_visibility_toggle_title)
recorder.assertSingle(SecurityAndPrivacyEvents.ToggleRoomVisibility)
}
@Test
@Config(qualifiers = "h640dp")
fun `click on history visibility item emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
editedSettings = aSecurityAndPrivacySettings(
historyVisibility = SecurityAndPrivacyHistoryVisibility.SinceSelection,
),
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_room_history_since_selecting_option_title)
recorder.assertSingle(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceSelection))
}
@Test
@Config(qualifiers = "h640dp")
fun `click on encryption item emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
savedSettings = aSecurityAndPrivacySettings(isEncrypted = false),
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_encryption_toggle_title)
recorder.assertSingle(SecurityAndPrivacyEvents.ToggleEncryptionState)
}
@Test
fun `click on encryption confirm emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
showEncryptionConfirmation = true,
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_enable_encryption_alert_confirm_button_title)
recorder.assertSingle(SecurityAndPrivacyEvents.ConfirmEnableEncryption)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSecurityAndPrivacyView(
state: SecurityAndPrivacyState = aSecurityAndPrivacyState(
eventSink = EventsRecorder(expectEvents = false),
),
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
SecurityAndPrivacyView(
state = state,
onBackClick = onBackClick,
)
}
}

View file

@ -1,372 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.aJoinedRoom
import io.element.android.features.roomdetails.impl.securityandprivacy.FakeSecurityAndPrivacyNavigator
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyNavigator
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.util.Optional
class EditBaseRoomAddressPresenterTest {
@Test
fun `present - initial state no address`() = runTest {
val presenter = createEditRoomAddressPresenter(
room = aJoinedRoom(displayName = "")
)
presenter.test {
with(awaitItem()) {
assertThat(homeserverName).isEqualTo("matrix.org")
assertThat(canBeSaved).isFalse()
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown)
assertThat(roomAddress).isEmpty()
}
}
}
@Test
fun `present - initial state address matching own homeserver`() = runTest {
val room = aJoinedRoom(
canonicalAlias = RoomAlias("#canonical:matrix.org"),
)
val presenter = createEditRoomAddressPresenter(room = room)
presenter.test {
with(awaitItem()) {
assertThat(homeserverName).isEqualTo("matrix.org")
assertThat(canBeSaved).isFalse()
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown)
assertThat(roomAddress).isEqualTo("canonical")
}
}
}
@Test
fun `present - initial state address not matching own homeserver`() = runTest {
val room = aJoinedRoom(
displayName = "",
canonicalAlias = RoomAlias("#canonical:notmatrix.org"),
)
val presenter = createEditRoomAddressPresenter(room = room)
presenter.test {
with(awaitItem()) {
assertThat(homeserverName).isEqualTo("matrix.org")
assertThat(canBeSaved).isFalse()
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown)
assertThat(roomAddress).isEmpty()
}
}
}
@Test
fun `present - room address change invalid state`() = runTest {
val roomAliasHelper = FakeRoomAliasHelper(
isRoomAliasValidLambda = { false }
)
val presenter = createEditRoomAddressPresenter(roomAliasHelper = roomAliasHelper)
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("invalid"))
}
with(awaitItem()) {
assertThat(roomAddress).isEqualTo("invalid")
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown)
}
with(awaitItem()) {
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.InvalidSymbols)
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - room address change valid state`() = runTest {
val presenter = createEditRoomAddressPresenter()
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("valid"))
}
with(awaitItem()) {
assertThat(roomAddress).isEqualTo("valid")
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown)
}
with(awaitItem()) {
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
assertThat(canBeSaved).isTrue()
}
}
}
@Test
fun `present - room address change alias unavailable`() = runTest {
val client = createMatrixClient(isAliasAvailable = false)
val presenter = createEditRoomAddressPresenter(client = client)
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("valid"))
}
with(awaitItem()) {
assertThat(roomAddress).isEqualTo("valid")
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown)
}
with(awaitItem()) {
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.NotAvailable)
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - save success no current alias`() = runTest {
val publishAliasInRoomDirectoryResult = lambdaRecorder<RoomAlias, Result<Boolean>> { _ -> Result.success(true) }
val updateCanonicalAliasResult = lambdaRecorder<RoomAlias?, List<RoomAlias>, Result<Unit>> { _, _ -> Result.success(Unit) }
val removeAliasFromRoomDirectoryResult = lambdaRecorder<RoomAlias, Result<Boolean>> { _ -> Result.success(true) }
val closeEditAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(
closeEditRoomAddressLambda = closeEditAddressLambda
)
val room = FakeJoinedRoom(
updateCanonicalAliasResult = updateCanonicalAliasResult,
publishRoomAliasInRoomDirectoryResult = publishAliasInRoomDirectoryResult
)
val presenter = createEditRoomAddressPresenter(room = room, navigator = navigator)
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("valid"))
}
skipItems(1)
with(awaitItem()) {
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
assertThat(canBeSaved).isTrue()
eventSink(EditRoomAddressEvents.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
val createdAlias = RoomAlias("#valid:matrix.org")
assert(updateCanonicalAliasResult)
.isCalledOnce()
.with(value(createdAlias), value(emptyList<RoomAlias>()))
assert(publishAliasInRoomDirectoryResult)
.isCalledOnce()
.with(value(createdAlias))
assert(removeAliasFromRoomDirectoryResult).isNeverCalled()
assert(closeEditAddressLambda).isCalledOnce()
}
}
@Test
fun `present - save success current canonical alias from own homeserver`() = runTest {
val publishAliasInRoomDirectoryResult = lambdaRecorder<RoomAlias, Result<Boolean>> { _ -> Result.success(true) }
val removeAliasFromRoomDirectoryResult = lambdaRecorder<RoomAlias, Result<Boolean>> { _ -> Result.success(true) }
val updateCanonicalAliasResult = lambdaRecorder<RoomAlias?, List<RoomAlias>, Result<Unit>> { _, _ -> Result.success(Unit) }
val closeEditAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(closeEditRoomAddressLambda = closeEditAddressLambda)
val canonicalAlias = RoomAlias("#canonical:matrix.org")
val room = aJoinedRoom(
canonicalAlias = canonicalAlias,
updateCanonicalAliasResult = updateCanonicalAliasResult,
publishRoomAliasInRoomDirectoryResult = publishAliasInRoomDirectoryResult,
removeRoomAliasFromRoomDirectoryResult = removeAliasFromRoomDirectoryResult
)
val presenter = createEditRoomAddressPresenter(room = room, navigator = navigator)
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("valid"))
}
skipItems(1)
with(awaitItem()) {
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
assertThat(canBeSaved).isTrue()
eventSink(EditRoomAddressEvents.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
val createdAlias = RoomAlias("#valid:matrix.org")
assert(updateCanonicalAliasResult)
.isCalledOnce()
.with(value(createdAlias), value(emptyList<RoomAlias>()))
assert(publishAliasInRoomDirectoryResult)
.isCalledOnce()
.with(value(createdAlias))
assert(removeAliasFromRoomDirectoryResult)
.isCalledOnce()
.with(value(canonicalAlias))
assert(closeEditAddressLambda).isCalledOnce()
}
}
@Test
fun `present - save success current canonical alias from other homeserver`() = runTest {
val publishAliasInRoomDirectoryResult = lambdaRecorder<RoomAlias, Result<Boolean>> { _ -> Result.success(true) }
val removeAliasFromRoomDirectoryResult = lambdaRecorder<RoomAlias, Result<Boolean>> { _ -> Result.success(true) }
val updateCanonicalAliasResult = lambdaRecorder<RoomAlias?, List<RoomAlias>, Result<Unit>> { _, _ -> Result.success(Unit) }
val closeEditAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(closeEditRoomAddressLambda = closeEditAddressLambda)
val canonicalAlias = RoomAlias("#canonical:notmatrix.org")
val room = aJoinedRoom(
canonicalAlias = canonicalAlias,
updateCanonicalAliasResult = updateCanonicalAliasResult,
publishRoomAliasInRoomDirectoryResult = publishAliasInRoomDirectoryResult,
removeRoomAliasFromRoomDirectoryResult = removeAliasFromRoomDirectoryResult
)
val presenter = createEditRoomAddressPresenter(room = room, navigator = navigator)
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("valid"))
}
skipItems(1)
with(awaitItem()) {
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
assertThat(canBeSaved).isTrue()
eventSink(EditRoomAddressEvents.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
val createdAlias = RoomAlias("#valid:matrix.org")
assert(updateCanonicalAliasResult)
.isCalledOnce()
.with(value(canonicalAlias), value(listOf(createdAlias)))
assert(publishAliasInRoomDirectoryResult)
.isCalledOnce()
.with(value(createdAlias))
assert(removeAliasFromRoomDirectoryResult).isNeverCalled()
assert(closeEditAddressLambda).isCalledOnce()
}
}
@Test
fun `present - save failure`() = runTest {
val closeEditAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(
closeEditRoomAddressLambda = closeEditAddressLambda
)
val presenter = createEditRoomAddressPresenter(
navigator = navigator,
room = FakeJoinedRoom(
publishRoomAliasInRoomDirectoryResult = {
Result.failure(AN_EXCEPTION)
},
)
)
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.RoomAddressChanged("valid"))
}
skipItems(1)
with(awaitItem()) {
assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid)
assertThat(canBeSaved).isTrue()
eventSink(EditRoomAddressEvents.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
with(awaitItem()) {
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java)
}
assert(closeEditAddressLambda).isNeverCalled()
}
}
@Test
fun `present - dismiss error`() = runTest {
val presenter = createEditRoomAddressPresenter(
room = FakeJoinedRoom(
publishRoomAliasInRoomDirectoryResult = {
Result.failure(AN_EXCEPTION)
},
)
)
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.Save)
}
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
with(awaitItem()) {
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java)
eventSink(EditRoomAddressEvents.DismissError)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = {
val resolvedRoomAlias = if (isAliasAvailable) {
Optional.empty()
} else {
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
}
Result.success(resolvedRoomAlias)
}
)
private fun createEditRoomAddressPresenter(
client: FakeMatrixClient = createMatrixClient(),
room: JoinedRoom = FakeJoinedRoom(),
navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(),
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper()
): EditRoomAddressPresenter {
return EditRoomAddressPresenter(
room = room,
client = client,
roomAliasHelper = roomAliasHelper,
navigator = navigator
)
}
}

View file

@ -1,117 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.roomdetails.impl.securityandprivacy.editroomaddress
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EditBaseRoomAddressViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `click on back invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setEditRoomAddressView(onBackClick = callback)
rule.pressBack()
}
}
@Test
fun `click on disabled save doesn't emit event`() {
val recorder = EventsRecorder<EditRoomAddressEvents>(expectEvents = false)
val state = anEditRoomAddressState(eventSink = recorder)
rule.setEditRoomAddressView(state)
rule.clickOn(CommonStrings.action_save)
recorder.assertEmpty()
}
@Test
fun `click on enabled save emits the expected event`() {
val recorder = EventsRecorder<EditRoomAddressEvents>()
val state = anEditRoomAddressState(
roomAddress = "room",
roomAddressValidity = RoomAddressValidity.Valid,
eventSink = recorder
)
rule.setEditRoomAddressView(state)
rule.clickOn(CommonStrings.action_save)
recorder.assertSingle(EditRoomAddressEvents.Save)
}
@Test
fun `text changes on text field emits the expected event`() {
val recorder = EventsRecorder<EditRoomAddressEvents>()
val state = anEditRoomAddressState(
roomAddress = "",
eventSink = recorder
)
rule.setEditRoomAddressView(state)
rule.onNodeWithTag(TestTags.roomAddressField.value).performTextInput("alias")
recorder.assertSingle(EditRoomAddressEvents.RoomAddressChanged("alias"))
}
@Test
fun `click on dismiss error emits the expected event`() {
val recorder = EventsRecorder<EditRoomAddressEvents>()
val state = anEditRoomAddressState(
roomAddress = "",
saveAction = AsyncAction.Failure(IllegalStateException()),
eventSink = recorder
)
rule.setEditRoomAddressView(state)
rule.clickOn(CommonStrings.action_cancel)
recorder.assertSingle(EditRoomAddressEvents.DismissError)
}
@Test
fun `click on retry error emits the expected event`() {
val recorder = EventsRecorder<EditRoomAddressEvents>()
val state = anEditRoomAddressState(
roomAddress = "",
saveAction = AsyncAction.Failure(IllegalStateException()),
eventSink = recorder
)
rule.setEditRoomAddressView(state)
rule.clickOn(CommonStrings.action_retry)
recorder.assertSingle(EditRoomAddressEvents.Save)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setEditRoomAddressView(
state: EditRoomAddressState = anEditRoomAddressState(
eventSink = EventsRecorder(expectEvents = false),
),
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
EditRoomAddressView(
state = state,
onBackClick = onBackClick,
)
}
}