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
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>

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