Merge pull request #4212 from element-hq/feature/fga/room_settings_security_privacy
Feature : room settings - security and privacy
This commit is contained in:
commit
346e3648e8
125 changed files with 3387 additions and 347 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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">"You’ll 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">"You’ll 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 & privacy"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">"You’ll 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">"You’ll 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 & 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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2701eb3f171f1724f328260dd80b0bd882e6f6f62adfb0caa76c0e727501b78d
|
||||
size 24635
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d0d5f98545b38207b0b61cc055112524b2b87c9969b41246a90d46554143905e
|
||||
size 28628
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c963f28503909ff8f965910922cefd82d4873904cee34bbbf4189b3a29b2fddb
|
||||
size 30877
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8965c0672a72d1af44bbbc6931ffa056a3b456265f641777c9f5bad8f9aed7fe
|
||||
size 24543
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:465436bd517d319aa8f5355e8e92630ac14d1253ff7f0dc5c856fca3e6dca816
|
||||
size 26504
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b8a158a83fe144502b24056f5ca3b439a7ee1934924394fe7ecbae01bf71ad40
|
||||
size 24170
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8073fcc88a65c07810373c176f790ebc1f41953294a36b44a6d21bd102482d4c
|
||||
size 28075
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f3e187b8590ec247fa2640db033fb34b8f8eb8c5bfee6a4598514049cf87da9
|
||||
size 29909
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b323513f415948d783b7a5d378e59195127a3e006ff3a52521a3153ec6003fac
|
||||
size 24017
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f90998d844da64b84b8a50506af0dd1a15f3a31591d9e2f3a7945792927365b8
|
||||
size 25141
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9f0d221bd5db5a13c9ba07a35ab9bb75d62e441c33941cf0012bf1a7e29d8b0d
|
||||
size 39650
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e0ddf4bd9f8571350c5ed4aee212950a399f65484434bd3d83742fba75fd47f1
|
||||
size 62264
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f02a50e9a947a59b0ff52a8c02e4fb9a2e254f5f85c5965ec9786c5fd38f4a6
|
||||
size 62401
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:78f2e0bc28d05baad8db0de7d3d1f9917c015a2e1fec8faae9425c0c66fdeba2
|
||||
size 60873
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ddef290ff73c2b824a586a589ac4dffb251830ace9114492aad6bca984e44c39
|
||||
size 62755
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7efc03a5388ae80383d4b60426f8664a5c54fd3c0a7e4d04bedf9280e8bba605
|
||||
size 39438
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7efc03a5388ae80383d4b60426f8664a5c54fd3c0a7e4d04bedf9280e8bba605
|
||||
size 39438
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8146f71450291e3164b2bd64407a57a1fd9282f0a36335f6a217ffa47b71e855
|
||||
size 43720
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:902d7aabd41c431a16cd9dfeb0ebbca3998a0f3ce7c81ce296edb1716d94288e
|
||||
size 33927
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2b31ed0dcf7db120ce77099c26c55196be64354f0bec2f80ae5176ac85194629
|
||||
size 41188
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c1031ba27d74bda6dbf76f1d81494a06c485d75fac709ea49edfcae66d123f31
|
||||
size 64290
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:141c74d7629662ef323fa12f558fec221204087e0da9b08a72ac06b451fbaeb6
|
||||
size 64522
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aa90d947a78dc8b71492107a03117ab30c6123a402781dcdc3223b93a982756a
|
||||
size 62738
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eeda9bd51bd1baec53af36d4102fd0264595710d2758807307d5abf5324b7262
|
||||
size 64828
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f1a5367fe47330eb9add526cc4d4e437ae2fe7e21aaafe95a8759fd918bb896
|
||||
size 41017
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f1a5367fe47330eb9add526cc4d4e437ae2fe7e21aaafe95a8759fd918bb896
|
||||
size 41017
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bf3619665bc8956d2b4bfc194464e95ff4ea1d397df4a17cc39e9c34d6751da1
|
||||
size 45886
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7fe40c0ee7536a8bbe308f786d8147f6b4482d35e6be6ee82801690cfec44ec8
|
||||
size 35160
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:709bd4347a1188fefe43a4da9b9c0c893f79383768b3f7a7c455f5057b824f13
|
||||
size 41767
|
||||
oid sha256:f883df9f8dd09fe8ac29cdeb0358e1b1f82fd0bf4f3b00778dcf2a436dc332a3
|
||||
size 42513
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:576724d5b9958897fad4544d20a0618303729297c4b172a6908bdfd51e06692f
|
||||
size 39781
|
||||
oid sha256:ec59f34715f2bdcc1dc1ca9718f05366aebf7973a027b261633934680f85d9b4
|
||||
size 41133
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d061fabbae9b28c800b553429b9ef747cea50ee17e219ed2aed9101f052beac3
|
||||
size 38712
|
||||
oid sha256:6cb196faae29f31e36f7e5a2f711e71755dbd76b0d7b2126cb79575628bef1f2
|
||||
size 40103
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8ba395fd405b9e7ff9e0d412e4f47001e09fbd7c7d22ccec267482d5b4c64580
|
||||
size 41921
|
||||
oid sha256:d17ebd1e51a73a8e213f792dc220125c77bc9143c9327bce9d25cb5573b63d89
|
||||
size 43278
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:60fb9372f93da45389dd0b6f97a3996e04ab46b5139472498ae29379e3fec64c
|
||||
size 40171
|
||||
oid sha256:bc0c8d01736a19447d3e0c810fe594578b3cbacd5de063a7e314df8da3b81cb6
|
||||
size 41415
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:de95e549a07106bdc8a016afd3e6449bdbe4006c25a9fae1c44893f28be74ace
|
||||
size 41391
|
||||
oid sha256:0c36a541e25043a7c85d2aeab088005afeb84b36354e8231ac10dcbe95c9c278
|
||||
size 42216
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fe506b8c3e544d7aa97db244334928fb0752ad507af7a648ca7f9b14516f6b7a
|
||||
size 41972
|
||||
oid sha256:afc35cdc58f53f14f4584dbed43744435aacfa6d3ddb0b3b5f0e0ab7e9a7cc16
|
||||
size 42799
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2c33cb4423e91f474aaf2461ba1edefa0c3188110fc28c875f54ec2ec595113b
|
||||
size 39734
|
||||
oid sha256:79061fd2609f0e61df7af26919d68f22b78864ce031a6fa2f397af419601c367
|
||||
size 32253
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c69a4deb8ff7bc7d49319458a14e1bf4c58965b683109b87386ce56a502b4516
|
||||
size 39845
|
||||
oid sha256:879817d4155151a61cdf642b91ccc344f38bfb1edbf75129302fd0a115fc16d6
|
||||
size 34378
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:42a45c5f60ef568a36f36e62f867b96f9efdde927e4359d67a891995c8ad51fb
|
||||
size 40477
|
||||
oid sha256:984798b0b880ee29f4f328e25ad531d56cd992f3fd3771b46d94fcbe1c1b9d8e
|
||||
size 41626
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue