feat(security&privacy) : first implementation of ui

This commit is contained in:
ganfra 2025-01-08 17:39:09 +01:00
parent d00bc308f8
commit 002325c574
6 changed files with 582 additions and 0 deletions

View file

@ -0,0 +1,16 @@
/*
* 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 Save : SecurityAndPrivacyEvents
data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvents
data object EnableEncryption: SecurityAndPrivacyEvents
data class ChangeHistoryVisibility(val historyVisibility: SecurityAndPrivacyHistoryVisibility) : SecurityAndPrivacyEvents
data class ChangeVisibleInRoomDirectory(val isVisibleInRoomDirectory: Boolean) : SecurityAndPrivacyEvents
}

View file

@ -0,0 +1,36 @@
/*
* 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 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>,
private val presenter: SecurityAndPrivacyPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SecurityAndPrivacyView(
state = state,
onBackClick = ::navigateUp,
modifier = modifier
)
}
}

View file

@ -0,0 +1,54 @@
/*
* 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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import java.util.Optional
import javax.inject.Inject
class SecurityAndPrivacyPresenter @Inject constructor() : Presenter<SecurityAndPrivacyState> {
@Composable
override fun present(): SecurityAndPrivacyState {
val savedSettings by remember {
mutableStateOf(
SecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.InviteOnly,
isEncrypted = true,
isVisibleInRoomDirectory = Optional.empty(),
historyVisibility = Optional.empty(),
formattedAddress = Optional.empty(),
)
)
}
fun handleEvents(event: SecurityAndPrivacyEvents) {
when (event) {
SecurityAndPrivacyEvents.Save -> {}
is SecurityAndPrivacyEvents.ChangeRoomAccess -> {}
is SecurityAndPrivacyEvents.EnableEncryption -> {}
is SecurityAndPrivacyEvents.ChangeHistoryVisibility -> {}
is SecurityAndPrivacyEvents.ChangeVisibleInRoomDirectory -> {}
}
}
return SecurityAndPrivacyState(
savedSettings = savedSettings,
currentSettings = savedSettings,
homeserverName = "",
canBeSaved = true,
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,51 @@
/*
* 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.libraries.architecture.AsyncData
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
data class SecurityAndPrivacyState(
val savedSettings: SecurityAndPrivacySettings,
val currentSettings: SecurityAndPrivacySettings,
val homeserverName: String,
val canBeSaved: Boolean,
val eventSink: (SecurityAndPrivacyEvents) -> Unit
) {
val showRoomVisibilitySections = currentSettings.roomAccess != SecurityAndPrivacyRoomAccess.InviteOnly && currentSettings.historyVisibility.isPresent
val availableHistoryVisibilities = buildSet {
add(SecurityAndPrivacyHistoryVisibility.SinceSelection)
if (currentSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone && !currentSettings.isEncrypted) {
add(SecurityAndPrivacyHistoryVisibility.Anyone)
} else {
add(SecurityAndPrivacyHistoryVisibility.SinceInvite)
}
if (savedSettings.historyVisibility.getOrNull() == SecurityAndPrivacyHistoryVisibility.SinceInvite) {
add(SecurityAndPrivacyHistoryVisibility.SinceInvite)
}
}
val showRoomHistoryVisibilitySection = availableHistoryVisibilities.isNotEmpty() && currentSettings.historyVisibility.isPresent
}
data class SecurityAndPrivacySettings(
val roomAccess: SecurityAndPrivacyRoomAccess,
val isEncrypted: Boolean,
val historyVisibility: Optional<SecurityAndPrivacyHistoryVisibility>,
val formattedAddress: Optional<String>,
val isVisibleInRoomDirectory: Optional<AsyncData<Boolean>>
)
enum class SecurityAndPrivacyHistoryVisibility {
SinceSelection, SinceInvite, Anyone
}
enum class SecurityAndPrivacyRoomAccess {
InviteOnly, AskToJoin, Anyone, SpaceMember
}

View file

@ -0,0 +1,74 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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.libraries.architecture.AsyncData
import java.util.Optional
open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> {
override val values: Sequence<SecurityAndPrivacyState>
get() = sequenceOf(
aSecurityAndPrivacyState(),
aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.AskToJoin
)
),
aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
isEncrypted = false,
)
),
aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember
)
),
aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings(
isVisibleInRoomDirectory = Optional.of(AsyncData.Loading())
)
),
aSecurityAndPrivacyState(
currentSettings = aSecurityAndPrivacySettings(
isVisibleInRoomDirectory = Optional.of(AsyncData.Success(true))
)
),
aSecurityAndPrivacyState(canBeSaved = false)
)
}
fun aSecurityAndPrivacySettings(
roomAccess: SecurityAndPrivacyRoomAccess = SecurityAndPrivacyRoomAccess.InviteOnly,
isEncrypted: Boolean = true,
formattedAddress: Optional<String> = Optional.empty(),
historyVisibility: Optional<SecurityAndPrivacyHistoryVisibility> = Optional.of(SecurityAndPrivacyHistoryVisibility.SinceSelection),
isVisibleInRoomDirectory: Optional<AsyncData<Boolean>> = Optional.empty()
) = SecurityAndPrivacySettings(
roomAccess = roomAccess,
isEncrypted = isEncrypted,
formattedAddress = formattedAddress,
historyVisibility = historyVisibility,
isVisibleInRoomDirectory = isVisibleInRoomDirectory
)
fun aSecurityAndPrivacyState(
currentSettings: SecurityAndPrivacySettings = aSecurityAndPrivacySettings(),
savedSettings: SecurityAndPrivacySettings = currentSettings,
canBeSaved: Boolean = true,
homeserverName: String = "myserver.xyz",
eventSink: (SecurityAndPrivacyEvents) -> Unit = {}
) = SecurityAndPrivacyState(
currentSettings = currentSettings,
savedSettings = savedSettings,
homeserverName = homeserverName,
canBeSaved = canBeSaved,
eventSink = eventSink
)

View file

@ -0,0 +1,351 @@
/*
* 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.button.BackButton
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 java.util.Optional
import kotlin.jvm.optionals.getOrNull
@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),
) {
RoomAccessSection(
modifier = Modifier.padding(top = 24.dp),
selected = state.currentSettings.roomAccess,
onSelected = { state.eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(it)) },
)
if (state.showRoomVisibilitySections) {
RoomVisibilitySection(state.homeserverName)
RoomAddressSection(
roomAddress = state.currentSettings.formattedAddress,
homeserverName = state.homeserverName,
onRoomAddressClick = { },
isVisibleInPublicDirectory = state.currentSettings.isVisibleInRoomDirectory,
onVisibilityChange = { },
)
}
EncryptionSection(
isEncryptionEnabled = state.currentSettings.isEncrypted,
onEnableEncryption = { state.eventSink(SecurityAndPrivacyEvents.EnableEncryption) },
)
if (state.showRoomHistoryVisibilitySection) {
RoomHistorySection(
selectedOption = state.currentSettings.historyVisibility.get(),
availableOptions = state.availableHistoryVisibilities,
onSelected = { state.eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(it)) },
)
}
}
}
}
@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(
selected: SecurityAndPrivacyRoomAccess,
onSelected: (SecurityAndPrivacyRoomAccess) -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(CommonStrings.screen_security_and_privacy_room_access_section_header),
modifier = modifier,
) {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_invite_only_option_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_invite_only_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = selected == SecurityAndPrivacyRoomAccess.InviteOnly),
onClick = { onSelected(SecurityAndPrivacyRoomAccess.InviteOnly) },
)
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_ask_to_join_option_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_ask_to_join_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = selected == SecurityAndPrivacyRoomAccess.AskToJoin),
onClick = { onSelected(SecurityAndPrivacyRoomAccess.AskToJoin) },
)
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_anyone_option_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_anyone_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = selected == SecurityAndPrivacyRoomAccess.Anyone),
onClick = { onSelected(SecurityAndPrivacyRoomAccess.Anyone) },
)
if (selected == SecurityAndPrivacyRoomAccess.SpaceMember) {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_space_members_option_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_access_space_members_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = true, enabled = false),
)
}
}
}
@Composable
private fun RoomVisibilitySection(
homeserverName: String,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(CommonStrings.screen_security_and_privacy_room_visibility_section_header),
modifier = modifier,
) {
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(CommonStrings.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: Optional<String>,
homeserverName: String,
isVisibleInPublicDirectory: Optional<AsyncData<Boolean>>,
onRoomAddressClick: () -> Unit,
onVisibilityChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(CommonStrings.screen_security_and_privacy_room_address_section_header),
modifier = modifier,
) {
ListItem(
headlineContent = {
Text(text = roomAddress.getOrNull() ?: stringResource(CommonStrings.screen_security_and_privacy_add_room_address_action))
},
trailingContent = if (roomAddress.isEmpty) ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())) else null,
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_address_section_footer)) },
onClick = onRoomAddressClick,
colors = ListItemDefaults.colors(trailingIconColor = ElementTheme.colors.iconAccentPrimary),
alwaysClickable = true
)
if (isVisibleInPublicDirectory.isPresent) {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_directory_visibility_toggle_title)) },
supportingContent = {
Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_directory_visibility_section_footer, homeserverName))
},
trailingContent =
when (val isVisible = isVisibleInPublicDirectory.get()) {
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 = isVisible.data,
onChange = onVisibilityChange
)
}
}
)
}
}
}
@Composable
private fun EncryptionSection(
isEncryptionEnabled: Boolean,
onEnableEncryption: () -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(CommonStrings.screen_security_and_privacy_encryption_section_header),
modifier = modifier,
) {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_encryption_toggle_title)) },
supportingContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_encryption_section_footer)) },
trailingContent = ListItemContent.Switch(
checked = isEncryptionEnabled,
enabled = !isEncryptionEnabled,
onChange = { onEnableEncryption() }
),
)
}
}
@Composable
private fun RoomHistorySection(
selectedOption: SecurityAndPrivacyHistoryVisibility,
availableOptions: Set<SecurityAndPrivacyHistoryVisibility>,
onSelected: (SecurityAndPrivacyHistoryVisibility) -> Unit,
modifier: Modifier = Modifier,
) {
SecurityAndPrivacySection(
title = stringResource(CommonStrings.screen_security_and_privacy_room_history_section_header),
modifier = modifier,
) {
Spacer(Modifier.height(16.dp))
for (availableOption in availableOptions) {
val isSelected = availableOption == selectedOption
when (availableOption) {
SecurityAndPrivacyHistoryVisibility.SinceSelection -> {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_history_since_selecting_option_title)) },
trailingContent = ListItemContent.RadioButton(selected = isSelected),
onClick = { onSelected(availableOption) },
)
}
SecurityAndPrivacyHistoryVisibility.SinceInvite -> {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_history_since_invite_option_title)) },
trailingContent = ListItemContent.RadioButton(selected = isSelected),
onClick = { onSelected(availableOption) },
)
}
SecurityAndPrivacyHistoryVisibility.Anyone -> {
ListItem(
headlineContent = { Text(text = stringResource(CommonStrings.screen_security_and_privacy_room_history_anyone_option_title)) },
trailingContent = ListItemContent.RadioButton(selected = isSelected),
onClick = { onSelected(availableOption) },
)
}
}
}
}
}
@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 = {},
)
}