Merge pull request #4212 from element-hq/feature/fga/room_settings_security_privacy

Feature : room settings - security and privacy
This commit is contained in:
ganfra 2025-01-29 17:29:56 +01:00 committed by GitHub
commit 346e3648e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
125 changed files with 3387 additions and 347 deletions

View file

@ -17,7 +17,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
@ -31,10 +30,11 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.roomAliasFromName
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
@ -42,12 +42,10 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Optional
import javax.inject.Inject
import kotlin.jvm.optionals.getOrNull
import kotlin.jvm.optionals.getOrDefault
class ConfigureRoomPresenter @Inject constructor(
private val dataStore: CreateRoomDataStore,
@ -96,7 +94,12 @@ class ConfigureRoomPresenter @Inject constructor(
}
}
RoomAddressValidityEffect(createRoomConfig.roomVisibility.roomAddress()) { newRoomAddressValidity ->
RoomAddressValidityEffect(
client = matrixClient,
roomAliasHelper = roomAliasHelper,
newRoomAddress = createRoomConfig.roomVisibility.roomAddress().getOrDefault(""),
knownRoomAddress = null,
) { newRoomAddressValidity ->
roomAddressValidity.value = newRoomAddressValidity
}
@ -146,39 +149,6 @@ class ConfigureRoomPresenter @Inject constructor(
)
}
@Composable
private fun RoomAddressValidityEffect(
roomAddress: Optional<String>,
onRoomAddressValidityChange: (RoomAddressValidity) -> Unit,
) {
val onChange by rememberUpdatedState(onRoomAddressValidityChange)
LaunchedEffect(roomAddress) {
val roomAliasName = roomAddress.getOrNull().orEmpty()
if (roomAliasName.isEmpty()) {
onChange(RoomAddressValidity.Unknown)
return@LaunchedEffect
}
// debounce the room address validation
delay(300)
val roomAlias = matrixClient.roomAliasFromName(roomAliasName).getOrNull()
if (roomAlias == null || !roomAliasHelper.isRoomAliasValid(roomAlias)) {
onChange(RoomAddressValidity.InvalidSymbols)
} else {
matrixClient.resolveRoomAlias(roomAlias)
.onSuccess { resolved ->
if (resolved.isPresent) {
onChange(RoomAddressValidity.NotAvailable)
} else {
onChange(RoomAddressValidity.Valid)
}
}
.onFailure {
onChange(RoomAddressValidity.Valid)
}
}
}
}
private fun CoroutineScope.createRoom(
config: CreateRoomConfig,
createRoomAction: MutableState<AsyncAction<RoomId>>
@ -191,7 +161,7 @@ class ConfigureRoomPresenter @Inject constructor(
topic = config.topic,
isEncrypted = false,
isDirect = false,
visibility = RoomVisibility.PUBLIC,
visibility = RoomVisibility.Public,
joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(),
preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId },
@ -204,7 +174,7 @@ class ConfigureRoomPresenter @Inject constructor(
topic = config.topic,
isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
isDirect = false,
visibility = RoomVisibility.PRIVATE,
visibility = RoomVisibility.Private,
preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,

View file

@ -11,6 +11,7 @@ import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.toImmutableList

View file

@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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
@ -58,6 +57,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.matrix.ui.room.address.RoomAddressField
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
@ -142,10 +142,12 @@ fun ConfigureRoomView(
)
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.roomVisibility.roomAddress,
address = state.config.roomVisibility.roomAddress.value,
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
label = stringResource(R.string.screen_create_room_room_address_section_title),
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
)
Spacer(Modifier)
}
@ -318,47 +320,6 @@ private fun RoomAccessOptions(
}
}
@Composable
private fun RoomAddressField(
address: RoomAddress,
homeserverName: String,
addressValidity: RoomAddressValidity,
onAddressChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(
modifier = modifier.fillMaxWidth(),
value = address.value,
label = stringResource(R.string.screen_create_room_room_address_section_title),
leadingIcon = {
Text(
text = "#",
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
trailingIcon = {
Text(
text = homeserverName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
supportingText = when (addressValidity) {
RoomAddressValidity.InvalidSymbols -> {
stringResource(CommonStrings.error_room_address_invalid_symbols)
}
RoomAddressValidity.NotAvailable -> {
stringResource(CommonStrings.error_room_address_already_exists)
}
else -> stringResource(R.string.screen_create_room_room_address_section_footer)
},
isError = addressValidity.isError(),
onValueChange = onAddressChange,
singleLine = true,
)
}
@PreviewWithLargeHeight
@Composable
internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =

View file

@ -7,16 +7,16 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.createroom.JoinRuleOverride
import io.element.android.libraries.matrix.api.room.join.JoinRule
enum class RoomAccess {
Anyone,
Knocking
}
fun RoomAccess.toJoinRule(): JoinRuleOverride {
fun RoomAccess.toJoinRule(): JoinRule? {
return when (this) {
RoomAccess.Anyone -> JoinRuleOverride.None
RoomAccess.Knocking -> JoinRuleOverride.Knock
RoomAccess.Anyone -> null
RoomAccess.Knocking -> JoinRule.Knock
}
}

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor

View file

@ -67,7 +67,10 @@ class IdentityChangeStatePresenterTest {
@Test
fun `present - when the clear room emits identity change, the presenter does not emit new state`() = runTest {
val room = FakeMatrixRoom(isEncrypted = false)
val room = FakeMatrixRoom(
isEncrypted = false,
enableEncryptionResult = { Result.success(Unit) }
)
val presenter = createIdentityChangeStatePresenter(room)
presenter.test {
val initialState = awaitItem()

View file

@ -33,6 +33,7 @@ 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.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
@ -114,6 +115,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object KnockRequestsList : NavTarget
@Parcelize
data object SecurityAndPrivacy : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -160,6 +164,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.KnockRequestsList)
}
override fun openSecurityAndPrivacy() {
backstack.push(NavTarget.SecurityAndPrivacy)
}
override fun onJoinCall() {
val inputs = CallType.RoomCall(
sessionId = room.sessionId,
@ -290,6 +298,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
NavTarget.KnockRequestsList -> {
knockRequestsListEntryPoint.createNode(this, buildContext)
}
NavTarget.SecurityAndPrivacy -> {
createNode<SecurityAndPrivacyFlowNode>(buildContext)
}
}
}

View file

@ -49,6 +49,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openAdminSettings()
fun openPinnedMessagesList()
fun openKnockRequestsList()
fun openSecurityAndPrivacy()
fun onJoinCall()
}
@ -121,6 +122,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openKnockRequestsList() }
}
private fun openSecurityAndPrivacy() {
callbacks.forEach { it.openSecurityAndPrivacy() }
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
@ -153,6 +158,7 @@ class RoomDetailsNode @AssistedInject constructor(
onJoinCallClick = ::onJoinCall,
onPinnedMessagesClick = ::openPinnedMessages,
onKnockRequestsClick = ::openKnockRequestsLists,
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy
)
}
}

View file

@ -9,7 +9,6 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -24,6 +23,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
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.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -104,7 +104,7 @@ class RoomDetailsPresenter @Inject constructor(
val dmMember by room.getDirectRoomMember(membersState)
val currentMember by room.getCurrentRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember, currentMember)
val roomType = getRoomType(dmMember, currentMember)
val roomCallState = roomCallStatePresenter.present()
val topicState = remember(canEditTopic, roomTopic, roomType) {
@ -147,10 +147,17 @@ class RoomDetailsPresenter @Inject constructor(
val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
val securityAndPrivacyPermissions = room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value)
val canShowSecurityAndPrivacy by remember {
derivedStateOf {
isKnockRequestsEnabled && roomType is RoomDetailsType.Room && securityAndPrivacyPermissions.value.hasAny
}
}
return RoomDetailsState(
roomId = room.roomId,
roomName = roomName,
roomAlias = room.alias,
roomAlias = room.canonicalAlias,
roomAvatarUrl = roomAvatar,
roomTopic = topicState,
memberCount = room.joinedMemberCount,
@ -172,6 +179,7 @@ class RoomDetailsPresenter @Inject constructor(
pinnedMessagesCount = pinnedMessagesCount,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
eventSink = ::handleEvents,
)
}
@ -187,16 +195,14 @@ class RoomDetailsPresenter @Inject constructor(
private fun getRoomType(
dmMember: RoomMember?,
currentMember: RoomMember?,
): State<RoomDetailsType> = remember(dmMember, currentMember) {
derivedStateOf {
if (dmMember != null && currentMember != null) {
RoomDetailsType.Dm(
me = currentMember,
otherMember = dmMember,
)
} else {
RoomDetailsType.Room
}
): RoomDetailsType = remember(dmMember, currentMember) {
if (dmMember != null && currentMember != null) {
RoomDetailsType.Dm(
me = currentMember,
otherMember = dmMember,
)
} else {
RoomDetailsType.Room
}
}

View file

@ -44,6 +44,7 @@ data class RoomDetailsState(
val pinnedMessagesCount: Int?,
val canShowKnockRequests: Boolean,
val knockRequestsCount: Int?,
val canShowSecurityAndPrivacy: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit
) {
val roomBadges = buildList {

View file

@ -107,6 +107,7 @@ fun aRoomDetailsState(
pinnedMessagesCount: Int? = null,
canShowKnockRequests: Boolean = false,
knockRequestsCount: Int? = null,
canShowSecurityAndPrivacy: Boolean = true,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@ -133,6 +134,7 @@ fun aRoomDetailsState(
pinnedMessagesCount = pinnedMessagesCount,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
eventSink = eventSink
)

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
@ -73,7 +72,6 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle
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.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
@ -106,6 +104,7 @@ fun RoomDetailsView(
onJoinCallClick: () -> Unit,
onPinnedMessagesClick: () -> Unit,
onKnockRequestsClick: () -> Unit,
onSecurityAndPrivacyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -184,25 +183,14 @@ fun RoomDetailsView(
state.eventSink(RoomDetailsEvent.SetFavorite(it))
}
)
if (state.canShowPinnedMessages) {
PinnedMessagesItem(
pinnedMessagesCount = state.pinnedMessagesCount,
onPinnedMessagesClick = onPinnedMessagesClick
)
}
if (state.displayRolesAndPermissionsSettings) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
onClick = openAdminSettings,
if (state.canShowSecurityAndPrivacy) {
SecurityAndPrivacyItem(
onClick = onSecurityAndPrivacyClick
)
}
}
val displayMemberListItem = state.roomType is RoomDetailsType.Room
if (displayMemberListItem) {
if (state.roomType is RoomDetailsType.Room) {
PreferenceCategory {
MembersItem(
memberCount = state.memberCount,
@ -214,19 +202,31 @@ fun RoomDetailsView(
onKnockRequestsClick = onKnockRequestsClick
)
}
if (state.displayRolesAndPermissionsSettings) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
onClick = openAdminSettings,
)
}
}
}
PollsSection(
openPollHistory = openPollHistory
)
if (state.canShowMediaGallery) {
MediaGallerySection(
onClick = openMediaGallery
PreferenceCategory {
if (state.canShowPinnedMessages) {
PinnedMessagesItem(
pinnedMessagesCount = state.pinnedMessagesCount,
onPinnedMessagesClick = onPinnedMessagesClick
)
}
PollsItem(
openPollHistory = openPollHistory
)
}
if (state.isEncrypted) {
SecuritySection()
if (state.canShowMediaGallery) {
MediaGalleryItem(
onClick = openMediaGallery
)
}
}
if (state.roomType is RoomDetailsType.Dm && state.roomMemberDetailsState != null) {
@ -408,24 +408,26 @@ private fun DmHeaderSection(
}
@Composable
private fun ColumnScope.TitleAndSubtitle(
private fun TitleAndSubtitle(
title: String,
subtitle: String?,
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = title,
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(6.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.secondary,
text = title,
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center,
)
}
}
}
@ -518,6 +520,19 @@ private fun NotificationItem(
)
}
@Composable
private fun SecurityAndPrivacyItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_security_and_privacy_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun FavoriteItem(
isFavorite: Boolean,
@ -569,40 +584,25 @@ private fun PinnedMessagesItem(
}
@Composable
private fun PollsSection(
private fun PollsItem(
openPollHistory: () -> Unit,
) {
PreferenceCategory {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_polls_history_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
onClick = openPollHistory,
)
}
ListItem(
headlineContent = { Text(stringResource(R.string.screen_polls_history_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
onClick = openPollHistory,
)
}
@Composable
private fun MediaGallerySection(
private fun MediaGalleryItem(
onClick: () -> Unit,
) {
PreferenceCategory {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
onClick = onClick,
)
}
}
@Composable
private fun SecuritySection() {
PreferenceCategory(title = stringResource(R.string.screen_room_details_security_title)) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_encryption_enabled_title)) },
supportingContent = { Text(stringResource(R.string.screen_room_details_encryption_enabled_subtitle)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_encryption_enabled)),
)
}
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
onClick = onClick,
)
}
@Composable
@ -654,5 +654,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
onJoinCallClick = {},
onPinnedMessagesClick = {},
onKnockRequestsClick = {},
onSecurityAndPrivacyClick = {},
)
}

View file

@ -0,0 +1,20 @@
/*
* 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

@ -0,0 +1,64 @@
/*
* 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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.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)
class SecurityAndPrivacyFlowNode @AssistedInject constructor(
@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

@ -0,0 +1,30 @@
/*
* 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

@ -0,0 +1,39 @@
/*
* 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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class SecurityAndPrivacyNode @AssistedInject constructor(
@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

@ -0,0 +1,283 @@
/*
* 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 dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.matchesServer
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.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
class SecurityAndPrivacyPresenter @AssistedInject constructor(
@Assisted private val navigator: SecurityAndPrivacyNavigator,
private val matrixClient: MatrixClient,
private val room: MatrixRoom,
) : Presenter<SecurityAndPrivacyState> {
@AssistedFactory
interface Factory {
fun create(navigator: SecurityAndPrivacyNavigator): SecurityAndPrivacyPresenter
}
@Composable
override fun present(): SecurityAndPrivacyState {
val coroutineScope = rememberCoroutineScope()
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val homeserverName = remember { matrixClient.userIdServerName() }
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomInfo = room.roomInfoFlow.collectAsState(null)
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory)
}
val savedSettings by remember {
derivedStateOf {
SecurityAndPrivacySettings(
roomAccess = roomInfo.value?.joinRule.map(),
isEncrypted = room.isEncrypted,
isVisibleInRoomDirectory = savedIsVisibleInRoomDirectory.value,
historyVisibility = roomInfo.value?.historyVisibility.map(),
address = roomInfo.value?.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 handleEvents(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,
saveAction = saveAction.value,
permissions = permissions,
eventSink = ::handleEvents
)
// 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 MatrixRoomInfo.firstDisplayableAlias(serverName: String): RoomAlias? {
return aliases.firstOrNull { it.matchesServer(serverName) } ?: aliases.firstOrNull()
}

View file

@ -0,0 +1,77 @@
/*
* 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 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

@ -0,0 +1,95 @@
/*
* 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
)
),
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
),
)
}
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
),
eventSink: (SecurityAndPrivacyEvents) -> Unit = {}
) = SecurityAndPrivacyState(
editedSettings = editedSettings,
savedSettings = savedSettings,
homeserverName = homeserverName,
showEnableEncryptionConfirmation = showEncryptionConfirmation,
saveAction = saveAction,
permissions = permissions,
eventSink = eventSink
)

View file

@ -0,0 +1,406 @@
/*
* 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.aliasScreenTitle
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,
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,
title = {
Text(
text = stringResource(R.string.screen_room_details_security_and_privacy_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
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,
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) },
)
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) },
)
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,
onChange = { onVisibilityChange() },
)
}
}
)
}
}
@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,
onChange = { onToggleEncryption() },
),
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

@ -0,0 +1,14 @@
/*
* 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

@ -0,0 +1,40 @@
/*
* 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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyNavigator
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class EditRoomAddressNode @AssistedInject constructor(
@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

@ -0,0 +1,143 @@
/*
* 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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.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.MatrixRoom
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
class EditRoomAddressPresenter @AssistedInject constructor(
@Assisted private val navigator: SecurityAndPrivacyNavigator,
private val client: MatrixClient,
private val room: MatrixRoom,
private val roomAliasHelper: RoomAliasHelper,
) : Presenter<EditRoomAddressState> {
@AssistedFactory
interface Factory {
fun create(navigator: SecurityAndPrivacyNavigator): EditRoomAddressPresenter
}
@Composable
override fun present(): EditRoomAddressState {
val coroutineScope = rememberCoroutineScope()
val homeserverName = remember { client.userIdServerName() }
val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
}
val savedRoomAddress = remember { room.firstAliasMatching(homeserverName)?.addressName() }
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
var newRoomAddress by remember {
mutableStateOf(
savedRoomAddress ?: roomAliasHelper.roomAliasNameFromRoomDisplayName(room.displayName)
)
}
fun handleEvents(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 = ::handleEvents
)
}
private fun CoroutineScope.save(
saveAction: MutableState<AsyncAction<Unit>>,
serverName: String,
newRoomAddress: String,
) = launch {
suspend {
val savedCanonicalAlias = room.canonicalAlias
val savedAliasFromHomeserver = room.firstAliasMatching(serverName)
val newRoomAlias = client.roomAliasFromName(newRoomAddress).getOrThrow()
// 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 = room.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(room.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 MatrixRoom.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

@ -0,0 +1,21 @@
/*
* 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

@ -0,0 +1,37 @@
/*
* 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

@ -0,0 +1,128 @@
/*
* 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.compound.theme.ElementTheme
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.aliasScreenTitle
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.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,
title = {
Text(
text = stringResource(R.string.screen_edit_room_address_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
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

@ -0,0 +1,24 @@
/*
* 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

@ -0,0 +1,49 @@
/*
* 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.MatrixRoom
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 MatrixRoom.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

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Youll need a room address in order to make it visible in the directory."</string>
<string name="screen_edit_room_address_title">"Room address"</string>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"An error occurred while updating the notification setting."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms."</string>
<string name="screen_polls_history_title">"Polls"</string>
@ -118,4 +120,37 @@
<string name="screen_room_roles_and_permissions_roles_header">"Roles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Room details"</string>
<string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Add room address"</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Anyone can ask to join the room but an administrator or moderator will have to accept the request."</string>
<string name="screen_security_and_privacy_ask_to_join_option_title">"Ask to join"</string>
<string name="screen_security_and_privacy_enable_encryption_alert_confirm_button_title">"Yes, enable encryption"</string>
<string name="screen_security_and_privacy_enable_encryption_alert_description">"Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room.
No one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly.
We do not recommend enabling encryption for rooms that anyone can find and join."</string>
<string name="screen_security_and_privacy_enable_encryption_alert_title">"Enable encryption?"</string>
<string name="screen_security_and_privacy_encryption_section_footer">"Once enabled, encryption cannot be disabled."</string>
<string name="screen_security_and_privacy_encryption_section_header">"Encryption"</string>
<string name="screen_security_and_privacy_encryption_toggle_title">"Enable end-to-end encryption"</string>
<string name="screen_security_and_privacy_room_access_anyone_option_description">"Anyone can find and join"</string>
<string name="screen_security_and_privacy_room_access_anyone_option_title">"Anyone"</string>
<string name="screen_security_and_privacy_room_access_invite_only_option_description">"People can only join if they are invited"</string>
<string name="screen_security_and_privacy_room_access_invite_only_option_title">"Invite only"</string>
<string name="screen_security_and_privacy_room_access_section_header">"Room access"</string>
<string name="screen_security_and_privacy_room_access_space_members_option_description">"Spaces are not currently supported"</string>
<string name="screen_security_and_privacy_room_access_space_members_option_title">"Space members"</string>
<string name="screen_security_and_privacy_room_address_section_footer">"Youll need a room address in order to make it visible in the room directory."</string>
<string name="screen_security_and_privacy_room_address_section_header">"Room address"</string>
<string name="screen_security_and_privacy_room_directory_visibility_section_footer">"Allow for this room to be found by searching %1$s public room directory"</string>
<string name="screen_security_and_privacy_room_directory_visibility_toggle_title">"Visible in public room directory"</string>
<string name="screen_security_and_privacy_room_history_anyone_option_title">"Anyone"</string>
<string name="screen_security_and_privacy_room_history_section_header">"Who can read history"</string>
<string name="screen_security_and_privacy_room_history_since_invite_option_title">"Members only since they were invited"</string>
<string name="screen_security_and_privacy_room_history_since_selecting_option_title">"Members only since selecting this option"</string>
<string name="screen_security_and_privacy_room_publishing_section_footer">"Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.
You can choose to publish your room in your homeserver public room directory."</string>
<string name="screen_security_and_privacy_room_publishing_section_header">"Room publishing"</string>
<string name="screen_security_and_privacy_room_visibility_section_footer">"Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.
The address is also required to make the room visible in %1$s public room directory."</string>
<string name="screen_security_and_privacy_room_visibility_section_header">"Room visibility"</string>
<string name="screen_security_and_privacy_title">"Security &amp; privacy"</string>
</resources>

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
@ -29,9 +30,11 @@ fun aMatrixRoom(
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
joinRule: JoinRule? = null,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
emitRoomInfo: Boolean = false,
canInviteResult: (UserId) -> Result<Boolean> = { lambdaError() },
canBanResult: (UserId) -> Result<Boolean> = { lambdaError() },
canSendStateResult: (UserId, StateEventType) -> Result<Boolean> = { _, _ -> lambdaError() },
userDisplayNameResult: (UserId) -> Result<String?> = { lambdaError() },
userAvatarUrlResult: () -> Result<String?> = { lambdaError() },
@ -51,6 +54,7 @@ fun aMatrixRoom(
isDirect = isDirect,
notificationSettingsService = notificationSettingsService,
canInviteResult = canInviteResult,
canBanResult = canBanResult,
canSendStateResult = canSendStateResult,
userDisplayNameResult = userDisplayNameResult,
userAvatarUrlResult = userAvatarUrlResult,
@ -70,6 +74,7 @@ fun aMatrixRoom(
avatarUrl = avatarUrl,
isDirect = isDirect,
isPublic = isPublic,
joinRule = joinRule,
)
)
}

View file

@ -24,6 +24,7 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.core.UserId
@ -31,6 +32,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
@ -75,6 +77,12 @@ class RoomDetailsPresenterTest {
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
mapOf(
FeatureFlags.NotificationSettings.key to true,
FeatureFlags.Knock.key to false,
)
),
isPinnedMessagesFeatureEnabled: Boolean = true,
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
@ -89,9 +97,6 @@ class RoomDetailsPresenterTest {
)
}
}
val featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.NotificationSettings.key to true)
)
return RoomDetailsPresenter(
client = matrixClient,
room = room,
@ -133,6 +138,7 @@ class RoomDetailsPresenterTest {
assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
assertThat(initialState.canShowPinnedMessages).isTrue()
assertThat(initialState.pinnedMessagesCount).isNull()
assertThat(initialState.canShowSecurityAndPrivacy).isFalse()
}
}
@ -270,8 +276,7 @@ class RoomDetailsPresenterTest {
when (stateEventType) {
StateEventType.ROOM_TOPIC -> Result.success(true)
StateEventType.ROOM_NAME -> Result.success(false)
StateEventType.ROOM_AVATAR -> Result.failure(Throwable("Whelp"))
else -> lambdaError()
else -> Result.failure(Throwable("Whelp"))
}
},
canInviteResult = { Result.success(false) },
@ -297,10 +302,10 @@ class RoomDetailsPresenterTest {
isDirect = true,
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_TOPIC -> Result.success(true)
StateEventType.ROOM_NAME -> Result.success(true)
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME,
StateEventType.ROOM_AVATAR -> Result.success(true)
else -> lambdaError()
else -> Result.failure(Throwable("Whelp"))
}
},
canInviteResult = { Result.success(false) },
@ -343,7 +348,7 @@ class RoomDetailsPresenterTest {
StateEventType.ROOM_AVATAR,
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME -> Result.success(true)
else -> lambdaError()
else -> Result.failure(Throwable("Whelp"))
}
},
canInviteResult = { Result.success(true) },
@ -376,10 +381,10 @@ class RoomDetailsPresenterTest {
val room = aMatrixRoom(
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_TOPIC -> Result.success(true)
StateEventType.ROOM_NAME -> Result.success(true)
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME,
StateEventType.ROOM_AVATAR -> Result.success(true)
else -> lambdaError()
else -> Result.failure(Throwable("Whelp"))
}
},
canInviteResult = {
@ -403,10 +408,10 @@ class RoomDetailsPresenterTest {
val room = aMatrixRoom(
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_TOPIC -> Result.success(false)
StateEventType.ROOM_NAME -> Result.success(false)
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME,
StateEventType.ROOM_AVATAR -> Result.success(false)
else -> lambdaError()
else -> Result.failure(Throwable("Whelp"))
}
},
canInviteResult = {
@ -432,7 +437,7 @@ class RoomDetailsPresenterTest {
StateEventType.ROOM_AVATAR,
StateEventType.ROOM_NAME -> Result.success(true)
StateEventType.ROOM_TOPIC -> Result.success(false)
else -> lambdaError()
else -> Result.failure(Throwable("Whelp"))
}
},
canInviteResult = {
@ -458,7 +463,7 @@ class RoomDetailsPresenterTest {
StateEventType.ROOM_AVATAR,
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME -> Result.success(true)
else -> lambdaError()
else -> Result.failure(Throwable("Whelp"))
}
},
canInviteResult = {
@ -632,4 +637,57 @@ class RoomDetailsPresenterTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show knock requests`() = runTest {
val room = aMatrixRoom(
emitRoomInfo = true,
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
joinRule = JoinRule.Knock,
)
val featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.Knock.key to false)
)
val presenter = createRoomDetailsPresenter(
room = room,
featureFlagService = featureFlagService,
)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(canShowKnockRequests).isFalse()
}
featureFlagService.setFeatureEnabled(FeatureFlags.Knock, true)
with(awaitItem()) {
assertThat(canShowKnockRequests).isTrue()
}
room.givenRoomInfo(aRoomInfo(joinRule = JoinRule.Private))
with(awaitItem()) {
assertThat(canShowKnockRequests).isFalse()
}
}
}
@Test
fun `present - show security and privacy`() = runTest {
val room = aMatrixRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
)
val featureFlagService = FakeFeatureFlagService()
val presenter = createRoomDetailsPresenter(room = room, featureFlagService = featureFlagService)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(canShowSecurityAndPrivacy).isFalse()
}
featureFlagService.setFeatureEnabled(FeatureFlags.Knock, true)
with(awaitItem()) {
assertThat(canShowSecurityAndPrivacy).isTrue()
}
}
}
}

View file

@ -144,6 +144,21 @@ class RoomDetailsViewTest {
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on security and privacy invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
canShowSecurityAndPrivacy = true,
),
onSecurityAndPrivacyClick = callback,
)
rule.clickOn(R.string.screen_room_details_security_and_privacy_title)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on add topic emit expected event`() {
@ -298,6 +313,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
onKnockRequestsClick: () -> Unit = EnsureNeverCalled(),
onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomDetailsView(
@ -315,6 +331,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
onJoinCallClick = onJoinCallClick,
onPinnedMessagesClick = onPinnedMessagesClick,
onKnockRequestsClick = onKnockRequestsClick,
onSecurityAndPrivacyClick = onSecurityAndPrivacyClick,
)
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.securityandprivacy
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyNavigator
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

@ -0,0 +1,348 @@
/*
* 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.securityandprivacy
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyEvents
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyHistoryVisibility
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyNavigator
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyPresenter
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyRoomAccess
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.MatrixRoom
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.FakeMatrixRoom
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()
}
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()
}
}
}
@Test
fun `present - room info change updates saved and edited settings`() = runTest {
val room = FakeMatrixRoom(
canSendStateResult = { _, _ -> Result.success(true) },
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(2)
room.givenRoomInfo(
aRoomInfo(
joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable,
canonicalAlias = A_ROOM_ALIAS,
)
)
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)
}
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isFalse()
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - room visibility loading and change`() = runTest {
val room = FakeMatrixRoom(
canSendStateResult = { _, _ -> Result.success(true) },
roomVisibilityResult = { Result.success(RoomVisibility.Private) }
)
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 = FakeMatrixRoom(
canSendStateResult = { _, _ -> Result.success(true) },
enableEncryptionResult = enableEncryptionLambda,
updateJoinRuleResult = updateJoinRuleLambda,
updateRoomVisibilityResult = updateRoomVisibilityLambda,
updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda,
roomVisibilityResult = { Result.success(RoomVisibility.Private) }
)
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 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 = FakeMatrixRoom(
canSendStateResult = { _, _ -> Result.success(true) },
enableEncryptionResult = enableEncryptionLambda,
updateJoinRuleResult = updateJoinRuleLambda,
updateRoomVisibilityResult = updateRoomVisibilityLambda,
updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda,
roomVisibilityResult = { Result.success(RoomVisibility.Private) }
)
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(2)
with(awaitItem()) {
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()
}
}
private fun createSecurityAndPrivacyPresenter(
serverName: String = "matrix.org",
room: MatrixRoom = FakeMatrixRoom(
canSendStateResult = { _, _ -> Result.success(true) },
roomVisibilityResult = { Result.success(RoomVisibility.Private) }
),
navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(),
): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter(
room = room,
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { serverName },
),
navigator = navigator
)
}
}

View file

@ -0,0 +1,171 @@
/*
* 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.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.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyEvents
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyHistoryVisibility
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyRoomAccess
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyState
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyView
import io.element.android.features.roomdetails.impl.securityandprivacy.aSecurityAndPrivacySettings
import io.element.android.features.roomdetails.impl.securityandprivacy.aSecurityAndPrivacyState
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 = "h640dp")
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

@ -0,0 +1,354 @@
/*
* 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.securityandprivacy.editroomaddress
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyNavigator
import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.EditRoomAddressEvents
import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.EditRoomAddressPresenter
import io.element.android.features.roomdetails.securityandprivacy.FakeSecurityAndPrivacyNavigator
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
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.FakeMatrixRoom
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 EditRoomAddressPresenterTest {
@Test
fun `present - initial state no address`() = runTest {
val presenter = createEditRoomAddressPresenter()
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 = FakeMatrixRoom(
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 = FakeMatrixRoom(
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 = FakeMatrixRoom(
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 = FakeMatrixRoom(
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 = FakeMatrixRoom(
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)
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()
presenter.test {
with(awaitItem()) {
eventSink(EditRoomAddressEvents.Save)
}
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: MatrixRoom = FakeMatrixRoom(),
navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(),
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper()
): EditRoomAddressPresenter {
return EditRoomAddressPresenter(
room = room,
client = client,
roomAliasHelper = roomAliasHelper,
navigator = navigator
)
}
}

View file

@ -0,0 +1,120 @@
/*
* 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.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.features.roomdetails.impl.securityandprivacy.editroomaddress.EditRoomAddressEvents
import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.EditRoomAddressState
import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.EditRoomAddressView
import io.element.android.features.roomdetails.impl.securityandprivacy.editroomaddress.anEditRoomAddressState
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 EditRoomAddressViewTest {
@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,
)
}
}

View file

@ -8,6 +8,8 @@
package io.element.android.libraries.matrix.api.createroom
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import java.util.Optional
data class CreateRoomParameters(
@ -19,6 +21,6 @@ data class CreateRoomParameters(
val preset: RoomPreset,
val invite: List<UserId>? = null,
val avatar: String? = null,
val joinRuleOverride: JoinRuleOverride = JoinRuleOverride.None,
val joinRuleOverride: JoinRule? = null,
val roomAliasName: Optional<String> = Optional.empty(),
)

View file

@ -1,16 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.createroom
/**
* Rules to override the default room join rules.
*/
sealed interface JoinRuleOverride {
data object Knock : JoinRuleOverride
data object None : JoinRuleOverride
}

View file

@ -1,12 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.createroom
enum class RoomVisibility {
PUBLIC,
PRIVATE,
}

View file

@ -24,10 +24,13 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -43,7 +46,7 @@ interface MatrixRoom : Closeable {
val sessionId: SessionId
val roomId: RoomId
val displayName: String
val alias: RoomAlias?
val canonicalAlias: RoomAlias?
val alternativeAliases: List<RoomAlias>
val topic: String?
val avatarUrl: String?
@ -403,4 +406,60 @@ interface MatrixRoom : Closeable {
suspend fun withdrawVerificationAndResend(userIds: List<UserId>, sendHandle: SendHandle): Result<Unit>
override fun close() = destroy()
/**
* Update the canonical alias of the room.
*
* Note that publishing the alias in the room directory is done separately.
*/
suspend fun updateCanonicalAlias(
canonicalAlias: RoomAlias?,
alternativeAliases: List<RoomAlias>
): Result<Unit>
/**
* Update the room's visibility in the room directory.
*/
suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result<Unit>
/**
* Update room history visibility for this room.
*/
suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result<Unit>
/**
* Returns the visibility for this room in the room directory.
* If the room is not published, the result will be [RoomVisibility.Private].
*/
suspend fun getRoomVisibility(): Result<RoomVisibility>
/**
* Publish a new room alias for this room in the room directory.
*
* Returns:
* - `true` if the room alias didn't exist and it's now published.
* - `false` if the room alias was already present so it couldn't be
* published.
*/
suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result<Boolean>
/**
* Remove an existing room alias for this room in the room directory.
*
* Returns:
* - `true` if the room alias was present and it's now removed from the
* room directory.
* - `false` if the room alias didn't exist so it couldn't be removed.
*/
suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result<Boolean>
/**
* Enable End-to-end encryption in this room.
*/
suspend fun enableEncryption(): Result<Unit>
/**
* Update the join rule for this room.
*/
suspend fun updateJoinRule(joinRule: JoinRule): Result<Unit>
}

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
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.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@ -71,6 +72,7 @@ data class MatrixRoomInfo(
val heroes: ImmutableList<MatrixUser>,
val pinnedEventIds: ImmutableList<EventId>,
val creator: UserId?,
val historyVisibility: RoomHistoryVisibility,
) {
val aliases: List<RoomAlias>
get() = listOfNotNull(canonicalAlias) + alternativeAliases

View file

@ -19,7 +19,7 @@ fun MatrixRoom.matches(roomIdOrAlias: RoomIdOrAlias): Boolean {
roomIdOrAlias.roomId == roomId
}
is RoomIdOrAlias.Alias -> {
roomIdOrAlias.roomAlias == alias || roomIdOrAlias.roomAlias in alternativeAliases
roomIdOrAlias.roomAlias == canonicalAlias || roomIdOrAlias.roomAlias in alternativeAliases
}
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.history
sealed interface RoomHistoryVisibility {
/**
* Previous events are accessible to newly joined members from the point
* they were invited onwards.
*
* Events stop being accessible when the member's state changes to
* something other than *invite* or *join*.
*/
data object Invited : RoomHistoryVisibility
/**
* Previous events are accessible to newly joined members from the point
* they joined the room onwards.
* Events stop being accessible when the member's state changes to
* something other than *join*.
*/
data object Joined : RoomHistoryVisibility
/**
* Previous events are always accessible to newly joined members.
*
* All events in the room are accessible, even those sent when the member
* was not a part of the room.
*/
data object Shared : RoomHistoryVisibility
/**
* All events while this is the `HistoryVisibility` value may be shared by
* any participating homeserver with anyone, regardless of whether they
* have ever joined the room.
*/
data object WorldReadable : RoomHistoryVisibility
/**
* A custom visibility value.
*/
data class Custom(val value: String) : RoomHistoryVisibility
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.roomdirectory
/**
* Enum class representing the visibility of a room in the room directory.
*/
sealed interface RoomVisibility {
/**
* Indicates that the room will be shown in the published room list.
*/
data object Public : RoomVisibility
/**
* Indicates that the room will not be shown in the published room list.
*/
data object Private : RoomVisibility
/**
* A custom value that's not present in the spec.
*/
data class Custom(val value: String) : RoomVisibility
}

View file

@ -23,9 +23,7 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.JoinRuleOverride
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService
@ -38,8 +36,10 @@ import io.element.android.libraries.matrix.api.room.PendingRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
@ -59,8 +59,10 @@ import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.libraries.matrix.impl.room.RustRoomFactory
import io.element.android.libraries.matrix.impl.room.TimelineEventTypeFilterFactory
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
@ -112,9 +114,7 @@ import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters
import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
class RustMatrixClient(
@ -310,36 +310,23 @@ class RustMatrixClient(
topic = createRoomParams.topic,
isEncrypted = createRoomParams.isEncrypted,
isDirect = createRoomParams.isDirect,
visibility = when (createRoomParams.visibility) {
RoomVisibility.PUBLIC -> RustRoomVisibility.Public
RoomVisibility.PRIVATE -> RustRoomVisibility.Private
},
preset = when (createRoomParams.visibility) {
RoomVisibility.PRIVATE -> {
if (createRoomParams.isDirect) {
RustRoomPreset.TRUSTED_PRIVATE_CHAT
} else {
RustRoomPreset.PRIVATE_CHAT
}
}
RoomVisibility.PUBLIC -> {
RustRoomPreset.PUBLIC_CHAT
}
visibility = createRoomParams.visibility.map(),
preset = when (createRoomParams.preset) {
RoomPreset.PRIVATE_CHAT -> RustRoomPreset.PRIVATE_CHAT
RoomPreset.TRUSTED_PRIVATE_CHAT -> RustRoomPreset.TRUSTED_PRIVATE_CHAT
RoomPreset.PUBLIC_CHAT -> RustRoomPreset.PUBLIC_CHAT
},
invite = createRoomParams.invite?.map { it.value },
avatar = createRoomParams.avatar,
powerLevelContentOverride = defaultRoomCreationPowerLevels.copy(
invite = if (createRoomParams.joinRuleOverride == JoinRuleOverride.Knock) {
invite = if (createRoomParams.joinRuleOverride == JoinRule.Knock) {
// override the invite power level so it's the same as kick.
RoomMember.Role.MODERATOR.powerLevel.toInt()
} else {
null
}
),
joinRuleOverride = when (createRoomParams.joinRuleOverride) {
JoinRuleOverride.Knock -> RustJoinRule.Knock
JoinRuleOverride.None -> null
},
joinRuleOverride = createRoomParams.joinRuleOverride?.map(),
canonicalAlias = createRoomParams.roomAliasName.getOrNull(),
)
val roomId = RoomId(innerClient.createRoom(rustParams))
@ -358,7 +345,7 @@ class RustMatrixClient(
name = null,
isEncrypted = true,
isDirect = true,
visibility = RoomVisibility.PRIVATE,
visibility = RoomVisibility.Private,
preset = RoomPreset.TRUSTED_PRIVATE_CHAT,
invite = listOf(userId),
)

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import kotlinx.collections.immutable.ImmutableMap
@ -60,6 +61,7 @@ class MatrixRoomInfoMapper {
numUnreadMessages = it.numUnreadMessages.toLong(),
numUnreadMentions = it.numUnreadMentions.toLong(),
numUnreadNotifications = it.numUnreadNotifications.toLong(),
historyVisibility = it.historyVisibility.map(),
)
}
}

View file

@ -38,11 +38,14 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -51,10 +54,13 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.impl.core.RustSendHandle
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.room.draft.into
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
import io.element.android.libraries.matrix.impl.util.MessageEventContent
@ -101,6 +107,7 @@ import org.matrix.rustcomponents.sdk.KnockRequest as InnerKnockRequest
import org.matrix.rustcomponents.sdk.Room as InnerRoom
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
@Suppress("LargeClass")
class RustMatrixRoom(
override val sessionId: SessionId,
private val deviceId: DeviceId,
@ -306,7 +313,7 @@ class RustMatrixRoom(
override val isEncrypted: Boolean
get() = runCatching { innerRoom.isEncrypted() }.getOrDefault(false)
override val alias: RoomAlias?
override val canonicalAlias: RoomAlias?
get() = runCatching { innerRoom.canonicalAlias()?.let(::RoomAlias) }.getOrDefault(null)
override val alternativeAliases: List<RoomAlias>
@ -805,6 +812,54 @@ class RustMatrixRoom(
}
}
override suspend fun updateCanonicalAlias(canonicalAlias: RoomAlias?, alternativeAliases: List<RoomAlias>): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.updateCanonicalAlias(canonicalAlias?.value, alternativeAliases.map { it.value })
}
}
override suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result<Boolean> = withContext(roomDispatcher) {
runCatching {
innerRoom.publishRoomAliasInRoomDirectory(roomAlias.value)
}
}
override suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result<Boolean> = withContext(roomDispatcher) {
runCatching {
innerRoom.removeRoomAliasFromRoomDirectory(roomAlias.value)
}
}
override suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.updateRoomVisibility(roomVisibility.map())
}
}
override suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.updateHistoryVisibility(historyVisibility.map())
}
}
override suspend fun getRoomVisibility(): Result<RoomVisibility> = withContext(roomDispatcher) {
runCatching {
innerRoom.getRoomVisibility().map()
}
}
override suspend fun enableEncryption(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.enableEncryption()
}
}
override suspend fun updateJoinRule(joinRule: JoinRule): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.updateJoinRules(joinRule.map())
}
}
private fun createTimeline(
timeline: InnerTimeline,
mode: Timeline.Mode,

View file

@ -0,0 +1,31 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.room.history
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import org.matrix.rustcomponents.sdk.RoomHistoryVisibility as RustRoomHistoryVisibility
fun RoomHistoryVisibility.map(): RustRoomHistoryVisibility {
return when (this) {
RoomHistoryVisibility.WorldReadable -> RustRoomHistoryVisibility.WorldReadable
RoomHistoryVisibility.Invited -> RustRoomHistoryVisibility.Invited
RoomHistoryVisibility.Joined -> RustRoomHistoryVisibility.Joined
RoomHistoryVisibility.Shared -> RustRoomHistoryVisibility.Shared
is RoomHistoryVisibility.Custom -> RustRoomHistoryVisibility.Custom(value)
}
}
fun RustRoomHistoryVisibility.map(): RoomHistoryVisibility {
return when (this) {
RustRoomHistoryVisibility.WorldReadable -> RoomHistoryVisibility.WorldReadable
RustRoomHistoryVisibility.Invited -> RoomHistoryVisibility.Invited
RustRoomHistoryVisibility.Joined -> RoomHistoryVisibility.Joined
RustRoomHistoryVisibility.Shared -> RoomHistoryVisibility.Shared
is RustRoomHistoryVisibility.Custom -> RoomHistoryVisibility.Custom(value)
}
}

View file

@ -17,3 +17,10 @@ fun RustAllowRule.map(): AllowRule {
is RustAllowRule.Custom -> AllowRule.Custom(json)
}
}
fun AllowRule.map(): RustAllowRule {
return when (this) {
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.toString())
is AllowRule.Custom -> RustAllowRule.Custom(json)
}
}

View file

@ -21,3 +21,15 @@ fun RustJoinRule.map(): JoinRule {
is RustJoinRule.KnockRestricted -> JoinRule.KnockRestricted(rules.map { it.map() })
}
}
fun JoinRule.map(): RustJoinRule {
return when (this) {
JoinRule.Public -> RustJoinRule.Public
JoinRule.Private -> RustJoinRule.Private
JoinRule.Knock -> RustJoinRule.Knock
JoinRule.Invite -> RustJoinRule.Invite
is JoinRule.Restricted -> RustJoinRule.Restricted(rules.map { it.map() })
is JoinRule.Custom -> RustJoinRule.Custom(value)
is JoinRule.KnockRestricted -> RustJoinRule.KnockRestricted(rules.map { it.map() })
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomdirectory
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility
fun RoomVisibility.map(): RustRoomVisibility {
return when (this) {
RoomVisibility.Public -> RustRoomVisibility.Public
RoomVisibility.Private -> RustRoomVisibility.Private
is RoomVisibility.Custom -> RustRoomVisibility.Custom(value)
}
}
fun RustRoomVisibility.map(): RoomVisibility {
return when (this) {
RustRoomVisibility.Public -> RoomVisibility.Public
RustRoomVisibility.Private -> RoomVisibility.Private
is RustRoomVisibility.Custom -> RoomVisibility.Custom(value)
}
}

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
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.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomHero
@ -33,6 +34,7 @@ import kotlinx.collections.immutable.toPersistentList
import org.junit.Test
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule
import org.matrix.rustcomponents.sdk.RoomHistoryVisibility as RustRoomHistoryVisibility
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
class MatrixRoomInfoMapperTest {
@ -72,6 +74,7 @@ class MatrixRoomInfoMapperTest {
numUnreadMentions = 14uL,
pinnedEventIds = listOf(AN_EVENT_ID.value),
roomCreator = A_USER_ID,
historyVisibility = RustRoomHistoryVisibility.Joined,
)
)
).isEqualTo(
@ -113,6 +116,7 @@ class MatrixRoomInfoMapperTest {
numUnreadMessages = 12L,
numUnreadNotifications = 13L,
numUnreadMentions = 14L,
historyVisibility = RoomHistoryVisibility.Joined,
)
)
}
@ -188,6 +192,7 @@ class MatrixRoomInfoMapperTest {
numUnreadMessages = 12L,
numUnreadNotifications = 13L,
numUnreadMentions = 14L,
historyVisibility = RoomHistoryVisibility.Joined,
)
)
}

View file

@ -33,10 +33,13 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -66,7 +69,7 @@ class FakeMatrixRoom(
override val topic: String? = null,
override val avatarUrl: String? = null,
override var isEncrypted: Boolean = false,
override val alias: RoomAlias? = null,
override val canonicalAlias: RoomAlias? = null,
override val alternativeAliases: List<RoomAlias> = emptyList(),
override val isPublic: Boolean = true,
override val isSpace: Boolean = false,
@ -145,6 +148,14 @@ class FakeMatrixRoom(
private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
private val ignoreDeviceTrustAndResendResult: (Map<UserId, List<DeviceId>>, SendHandle) -> Result<Unit> = { _, _ -> lambdaError() },
private val withdrawVerificationAndResendResult: (List<UserId>, SendHandle) -> Result<Unit> = { _, _ -> lambdaError() },
private val updateCanonicalAliasResult: (RoomAlias?, List<RoomAlias>) -> Result<Unit> = { _, _ -> lambdaError() },
private val updateRoomVisibilityResult: (RoomVisibility) -> Result<Unit> = { lambdaError() },
private val updateRoomHistoryVisibilityResult: (RoomHistoryVisibility) -> Result<Unit> = { lambdaError() },
private val roomVisibilityResult: () -> Result<RoomVisibility> = { lambdaError() },
private val publishRoomAliasInRoomDirectoryResult: (RoomAlias) -> Result<Boolean> = { lambdaError() },
private val removeRoomAliasFromRoomDirectoryResult: (RoomAlias) -> Result<Boolean> = { lambdaError() },
private val enableEncryptionResult: () -> Result<Unit> = { lambdaError() },
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
) : MatrixRoom {
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
@ -195,9 +206,11 @@ class FakeMatrixRoom(
return Result.success(Unit)
}
fun enableEncryption() {
isEncrypted = true
emitSyncUpdate()
override suspend fun enableEncryption(): Result<Unit> = simulateLongTask {
enableEncryptionResult().onSuccess {
isEncrypted = true
emitSyncUpdate()
}
}
private val _syncUpdateFlow = MutableStateFlow(0L)
@ -582,6 +595,34 @@ class FakeMatrixRoom(
return withdrawVerificationAndResendResult(userIds, sendHandle)
}
override suspend fun updateCanonicalAlias(canonicalAlias: RoomAlias?, alternativeAliases: List<RoomAlias>): Result<Unit> = simulateLongTask {
updateCanonicalAliasResult(canonicalAlias, alternativeAliases)
}
override suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result<Unit> = simulateLongTask {
updateRoomVisibilityResult(roomVisibility)
}
override suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result<Unit> = simulateLongTask {
updateRoomHistoryVisibilityResult(historyVisibility)
}
override suspend fun getRoomVisibility(): Result<RoomVisibility> = simulateLongTask {
roomVisibilityResult()
}
override suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result<Boolean> = simulateLongTask {
publishRoomAliasInRoomDirectoryResult(roomAlias)
}
override suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result<Boolean> = simulateLongTask {
removeRoomAliasFromRoomDirectoryResult(roomAlias)
}
override suspend fun updateJoinRule(joinRule: JoinRule): Result<Unit> = simulateLongTask {
updateJoinRuleResult(joinRule)
}
fun givenRoomMembersState(state: MatrixRoomMembersState) {
membersStateFlow.value = state
}

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
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.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -58,6 +59,7 @@ fun aRoomInfo(
numUnreadMessages: Long = 0,
numUnreadNotifications: Long = 0,
numUnreadMentions: Long = 0,
historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined,
) = MatrixRoomInfo(
id = id,
name = name,
@ -90,4 +92,5 @@ fun aRoomInfo(
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
numUnreadMentions = numUnreadMentions,
historyVisibility = historyVisibility,
)

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@ -71,6 +72,7 @@ fun aRoomSummary(
numUnreadMessages: Long = 0,
numUnreadNotifications: Long = 0,
numUnreadMentions: Long = 0,
historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined,
lastMessage: RoomMessage? = aRoomMessage(),
) = RoomSummary(
info = MatrixRoomInfo(
@ -105,6 +107,7 @@ fun aRoomSummary(
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
numUnreadMentions = numUnreadMentions,
historyVisibility = historyVisibility,
),
lastMessage = lastMessage,
)

View file

@ -0,0 +1,76 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.room.address
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RoomAddressField(
address: String,
homeserverName: String,
addressValidity: RoomAddressValidity,
onAddressChange: (String) -> Unit,
label: String,
supportingText: String,
modifier: Modifier = Modifier,
) {
TextField(
modifier = modifier.testTag(TestTags.roomAddressField),
value = address,
label = label,
leadingIcon = {
Text(
text = "#",
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
trailingIcon = {
Text(
text = homeserverName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
supportingText = when (addressValidity) {
RoomAddressValidity.InvalidSymbols -> {
stringResource(CommonStrings.error_room_address_invalid_symbols)
}
RoomAddressValidity.NotAvailable -> {
stringResource(CommonStrings.error_room_address_already_exists)
}
else -> supportingText
},
isError = addressValidity.isError(),
onValueChange = onAddressChange,
singleLine = true,
)
}
@PreviewsDayNight
@Composable
internal fun RoomAddressFieldPreview() = ElementPreview {
RoomAddressField(
address = "room",
homeserverName = "element.io",
addressValidity = RoomAddressValidity.Valid,
onAddressChange = {},
label = "Room address",
supportingText = "This is the address that people will use to join your room",
)
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
package io.element.android.libraries.matrix.ui.room.address
import androidx.compose.runtime.Immutable

View file

@ -0,0 +1,52 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.room.address
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.roomAliasFromName
import kotlinx.coroutines.delay
@Composable
fun RoomAddressValidityEffect(
client: MatrixClient,
roomAliasHelper: RoomAliasHelper,
newRoomAddress: String,
knownRoomAddress: String?,
onRoomAddressValidityChange: (RoomAddressValidity) -> Unit,
) {
val onChange by rememberUpdatedState(onRoomAddressValidityChange)
LaunchedEffect(newRoomAddress) {
if (newRoomAddress.isEmpty() || newRoomAddress == knownRoomAddress) {
onChange(RoomAddressValidity.Unknown)
return@LaunchedEffect
}
// debounce the room address validation
delay(300)
val roomAlias = client.roomAliasFromName(newRoomAddress).getOrNull()
if (roomAlias == null || !roomAliasHelper.isRoomAliasValid(roomAlias)) {
onChange(RoomAddressValidity.InvalidSymbols)
} else {
client.resolveRoomAlias(roomAlias)
.onSuccess { resolved ->
if (resolved.isPresent) {
onChange(RoomAddressValidity.NotAvailable)
} else {
onChange(RoomAddressValidity.Valid)
}
}
.onFailure {
onChange(RoomAddressValidity.Valid)
}
}
}
}

View file

@ -111,4 +111,10 @@ object TestTags {
* Generic call to action.
*/
val callToAction = TestTag("call_to_action")
/**
* Room address field.
*
*/
val roomAddressField = TestTag("room_address_field")
}

View file

@ -310,8 +310,6 @@ Reason: %1$s."</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_edit_room_address_room_address_section_footer">"Youll need a room address in order to make it visible in the directory."</string>
<string name="screen_edit_room_address_title">"Room address"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
@ -341,39 +339,6 @@ Reason: %1$s."</string>
<string name="screen_room_pinned_banner_view_all_button_title">"View All"</string>
<string name="screen_room_title">"Chat"</string>
<string name="screen_roomlist_knock_event_sent_description">"Request to join sent"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Add room address"</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Anyone can ask to join the room but an administrator or moderator will have to accept the request."</string>
<string name="screen_security_and_privacy_ask_to_join_option_title">"Ask to join"</string>
<string name="screen_security_and_privacy_enable_encryption_alert_confirm_button_title">"Yes, enable encryption"</string>
<string name="screen_security_and_privacy_enable_encryption_alert_description">"Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room.
No one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly.
We do not recommend enabling encryption for rooms that anyone can find and join."</string>
<string name="screen_security_and_privacy_enable_encryption_alert_title">"Enable encryption?"</string>
<string name="screen_security_and_privacy_encryption_section_footer">"Once enabled, encryption cannot be disabled."</string>
<string name="screen_security_and_privacy_encryption_section_header">"Encryption"</string>
<string name="screen_security_and_privacy_encryption_toggle_title">"Enable end-to-end encryption"</string>
<string name="screen_security_and_privacy_room_access_anyone_option_description">"Anyone can find and join"</string>
<string name="screen_security_and_privacy_room_access_anyone_option_title">"Anyone"</string>
<string name="screen_security_and_privacy_room_access_invite_only_option_description">"People can only join if they are invited"</string>
<string name="screen_security_and_privacy_room_access_invite_only_option_title">"Invite only"</string>
<string name="screen_security_and_privacy_room_access_section_header">"Room access"</string>
<string name="screen_security_and_privacy_room_access_space_members_option_description">"Spaces are not currently supported"</string>
<string name="screen_security_and_privacy_room_access_space_members_option_title">"Space members"</string>
<string name="screen_security_and_privacy_room_address_section_footer">"Youll need a room address in order to make it visible in the room directory."</string>
<string name="screen_security_and_privacy_room_address_section_header">"Room address"</string>
<string name="screen_security_and_privacy_room_directory_visibility_section_footer">"Allow for this room to be found by searching %1$s public room directory"</string>
<string name="screen_security_and_privacy_room_directory_visibility_toggle_title">"Visible in public room directory"</string>
<string name="screen_security_and_privacy_room_history_anyone_option_title">"Anyone"</string>
<string name="screen_security_and_privacy_room_history_section_header">"Who can read history"</string>
<string name="screen_security_and_privacy_room_history_since_invite_option_title">"Members only since they were invited"</string>
<string name="screen_security_and_privacy_room_history_since_selecting_option_title">"Members only since selecting this option"</string>
<string name="screen_security_and_privacy_room_publishing_section_footer">"Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.
You can choose to publish your room in your homeserver public room directory."</string>
<string name="screen_security_and_privacy_room_publishing_section_header">"Room publishing"</string>
<string name="screen_security_and_privacy_room_visibility_section_footer">"Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others.
The address is also required to make the room visible in %1$s public room directory."</string>
<string name="screen_security_and_privacy_room_visibility_section_header">"Room visibility"</string>
<string name="screen_security_and_privacy_title">"Security &amp; privacy"</string>
<string name="screen_share_location_title">"Share location"</string>
<string name="screen_share_my_location_action">"Share my location"</string>
<string name="screen_share_open_apple_maps">"Open in Apple Maps"</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2701eb3f171f1724f328260dd80b0bd882e6f6f62adfb0caa76c0e727501b78d
size 24635

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d0d5f98545b38207b0b61cc055112524b2b87c9969b41246a90d46554143905e
size 28628

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c963f28503909ff8f965910922cefd82d4873904cee34bbbf4189b3a29b2fddb
size 30877

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8965c0672a72d1af44bbbc6931ffa056a3b456265f641777c9f5bad8f9aed7fe
size 24543

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:465436bd517d319aa8f5355e8e92630ac14d1253ff7f0dc5c856fca3e6dca816
size 26504

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b8a158a83fe144502b24056f5ca3b439a7ee1934924394fe7ecbae01bf71ad40
size 24170

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8073fcc88a65c07810373c176f790ebc1f41953294a36b44a6d21bd102482d4c
size 28075

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f3e187b8590ec247fa2640db033fb34b8f8eb8c5bfee6a4598514049cf87da9
size 29909

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b323513f415948d783b7a5d378e59195127a3e006ff3a52521a3153ec6003fac
size 24017

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f90998d844da64b84b8a50506af0dd1a15f3a31591d9e2f3a7945792927365b8
size 25141

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9f0d221bd5db5a13c9ba07a35ab9bb75d62e441c33941cf0012bf1a7e29d8b0d
size 39650

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e0ddf4bd9f8571350c5ed4aee212950a399f65484434bd3d83742fba75fd47f1
size 62264

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f02a50e9a947a59b0ff52a8c02e4fb9a2e254f5f85c5965ec9786c5fd38f4a6
size 62401

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78f2e0bc28d05baad8db0de7d3d1f9917c015a2e1fec8faae9425c0c66fdeba2
size 60873

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ddef290ff73c2b824a586a589ac4dffb251830ace9114492aad6bca984e44c39
size 62755

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7efc03a5388ae80383d4b60426f8664a5c54fd3c0a7e4d04bedf9280e8bba605
size 39438

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7efc03a5388ae80383d4b60426f8664a5c54fd3c0a7e4d04bedf9280e8bba605
size 39438

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8146f71450291e3164b2bd64407a57a1fd9282f0a36335f6a217ffa47b71e855
size 43720

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:902d7aabd41c431a16cd9dfeb0ebbca3998a0f3ce7c81ce296edb1716d94288e
size 33927

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2b31ed0dcf7db120ce77099c26c55196be64354f0bec2f80ae5176ac85194629
size 41188

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1031ba27d74bda6dbf76f1d81494a06c485d75fac709ea49edfcae66d123f31
size 64290

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:141c74d7629662ef323fa12f558fec221204087e0da9b08a72ac06b451fbaeb6
size 64522

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aa90d947a78dc8b71492107a03117ab30c6123a402781dcdc3223b93a982756a
size 62738

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eeda9bd51bd1baec53af36d4102fd0264595710d2758807307d5abf5324b7262
size 64828

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f1a5367fe47330eb9add526cc4d4e437ae2fe7e21aaafe95a8759fd918bb896
size 41017

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f1a5367fe47330eb9add526cc4d4e437ae2fe7e21aaafe95a8759fd918bb896
size 41017

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bf3619665bc8956d2b4bfc194464e95ff4ea1d397df4a17cc39e9c34d6751da1
size 45886

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7fe40c0ee7536a8bbe308f786d8147f6b4482d35e6be6ee82801690cfec44ec8
size 35160

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:709bd4347a1188fefe43a4da9b9c0c893f79383768b3f7a7c455f5057b824f13
size 41767
oid sha256:f883df9f8dd09fe8ac29cdeb0358e1b1f82fd0bf4f3b00778dcf2a436dc332a3
size 42513

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:576724d5b9958897fad4544d20a0618303729297c4b172a6908bdfd51e06692f
size 39781
oid sha256:ec59f34715f2bdcc1dc1ca9718f05366aebf7973a027b261633934680f85d9b4
size 41133

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d061fabbae9b28c800b553429b9ef747cea50ee17e219ed2aed9101f052beac3
size 38712
oid sha256:6cb196faae29f31e36f7e5a2f711e71755dbd76b0d7b2126cb79575628bef1f2
size 40103

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8ba395fd405b9e7ff9e0d412e4f47001e09fbd7c7d22ccec267482d5b4c64580
size 41921
oid sha256:d17ebd1e51a73a8e213f792dc220125c77bc9143c9327bce9d25cb5573b63d89
size 43278

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60fb9372f93da45389dd0b6f97a3996e04ab46b5139472498ae29379e3fec64c
size 40171
oid sha256:bc0c8d01736a19447d3e0c810fe594578b3cbacd5de063a7e314df8da3b81cb6
size 41415

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:de95e549a07106bdc8a016afd3e6449bdbe4006c25a9fae1c44893f28be74ace
size 41391
oid sha256:0c36a541e25043a7c85d2aeab088005afeb84b36354e8231ac10dcbe95c9c278
size 42216

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fe506b8c3e544d7aa97db244334928fb0752ad507af7a648ca7f9b14516f6b7a
size 41972
oid sha256:afc35cdc58f53f14f4584dbed43744435aacfa6d3ddb0b3b5f0e0ab7e9a7cc16
size 42799

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c33cb4423e91f474aaf2461ba1edefa0c3188110fc28c875f54ec2ec595113b
size 39734
oid sha256:79061fd2609f0e61df7af26919d68f22b78864ce031a6fa2f397af419601c367
size 32253

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c69a4deb8ff7bc7d49319458a14e1bf4c58965b683109b87386ce56a502b4516
size 39845
oid sha256:879817d4155151a61cdf642b91ccc344f38bfb1edbf75129302fd0a115fc16d6
size 34378

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:42a45c5f60ef568a36f36e62f867b96f9efdde927e4359d67a891995c8ad51fb
size 40477
oid sha256:984798b0b880ee29f4f328e25ad531d56cd992f3fd3771b46d94fcbe1c1b9d8e
size 41626

Some files were not shown because too many files have changed in this diff Show more