Crypto: iterate on wording, UI and UX.

Change wording of setup recovery key banner and change target to root.
Iterate on wording of encryption screen.
Change button to Switch.
Iterate on wording to delete key storage.
Iterate on wording and icon on the root setting.
Remove confirmation dialog when disabling backup.
Add subtitle to change recovery key action.
Enable key storage directly, remove quite empty screen to setup the backup.
Disable recovery action if key backup is disabled.
This commit is contained in:
Benoit Marty
2024-10-29 14:54:48 +01:00
parent e26802d8d3
commit fe16a29283
24 changed files with 284 additions and 527 deletions

View File

@@ -260,7 +260,7 @@ class LoggedInFlowNode @AssistedInject constructor(
}
override fun onSetUpRecoveryClick() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.SetUpRecovery))
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
}
override fun onSessionConfirmRecoveryKeyClick() {

View File

@@ -138,8 +138,8 @@ private fun ColumnScope.ManageAppSection(
}
if (state.showSecureBackup) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.KeySolid())),
headlineContent = { Text(stringResource(id = CommonStrings.common_encryption)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())),
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
onClick = onSecureBackupClick,
)

View File

@@ -25,6 +25,7 @@ internal fun SetUpRecoveryKeyBanner(
modifier = modifier,
title = stringResource(R.string.banner_set_up_recovery_title),
content = stringResource(R.string.banner_set_up_recovery_content),
actionText = stringResource(R.string.banner_set_up_recovery_submit),
onSubmitClick = onContinueClick,
onDismissClick = onDismissClick,
)

View File

@@ -4,8 +4,9 @@
<string name="banner_migrate_to_native_sliding_sync_description">"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."</string>
<string name="banner_migrate_to_native_sliding_sync_title">"Upgrade available"</string>
<string name="banner_set_up_recovery_content">"Generate a new recovery key that can be used to restore your encrypted message history in case you lose access to your devices."</string>
<string name="banner_set_up_recovery_title">"Set up recovery"</string>
<string name="banner_set_up_recovery_content">"Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."</string>
<string name="banner_set_up_recovery_submit">"Set up recovery"</string>
<string name="banner_set_up_recovery_title">"Set up recovery to protect your account"</string>
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string>
<string name="confirm_recovery_key_banner_title">"Enter your recovery key"</string>
<string name="full_screen_intent_banner_message">"To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."</string>

View File

@@ -121,12 +121,9 @@ class RoomListViewTest {
),
onSetUpRecoveryClick = callback,
)
// Remove automatic initial events
eventsRecorder.clear()
rule.clickOn(CommonStrings.action_continue)
rule.clickOn(R.string.banner_set_up_recovery_submit)
eventsRecorder.assertEmpty()
}
}

View File

@@ -22,7 +22,6 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
import io.element.android.features.securebackup.impl.reset.ResetIdentityFlowNode
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
@@ -63,9 +62,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Parcelize
data object Disable : NavTarget
@Parcelize
data object Enable : NavTarget
@Parcelize
data object EnterRecoveryKey : NavTarget
@@ -91,10 +87,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Disable)
}
override fun onEnableClick() {
backstack.push(NavTarget.Enable)
}
override fun onConfirmRecoveryKeyClick() {
backstack.push(NavTarget.EnterRecoveryKey)
}
@@ -116,9 +108,6 @@ class SecureBackupFlowNode @AssistedInject constructor(
NavTarget.Disable -> {
createNode<SecureBackupDisableNode>(buildContext)
}
NavTarget.Enable -> {
createNode<SecureBackupEnableNode>(buildContext)
}
NavTarget.EnterRecoveryKey -> {
val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
override fun onEnterRecoveryKeySuccess() {

View File

@@ -37,11 +37,7 @@ class SecureBackupDisablePresenter @Inject constructor(
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: SecureBackupDisableEvents) {
when (event) {
is SecureBackupDisableEvents.DisableBackup -> if (disableAction.value.isConfirming()) {
coroutineScope.disableBackup(disableAction)
} else {
disableAction.value = AsyncAction.ConfirmingNoParams
}
is SecureBackupDisableEvents.DisableBackup -> coroutineScope.disableBackup(disableAction)
SecureBackupDisableEvents.DismissDialogs -> {
disableAction.value = AsyncAction.Uninitialized
}

View File

@@ -7,6 +7,7 @@
package io.element.android.features.securebackup.impl.disable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -25,7 +26,6 @@ import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -44,7 +44,7 @@ fun SecureBackupDisableView(
onBackClick = onBackClick,
title = stringResource(id = R.string.screen_key_backup_disable_title),
subTitle = stringResource(id = R.string.screen_key_backup_disable_description),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeyOffSolid()),
iconStyle = BigIcon.Style.AlertSolid,
buttons = { Buttons(state = state) },
) {
Content(state = state)
@@ -52,12 +52,6 @@ fun SecureBackupDisableView(
AsyncActionView(
async = state.disableAction,
confirmationDialog = {
SecureBackupDisableConfirmationDialog(
onConfirm = { state.eventSink.invoke(SecureBackupDisableEvents.DisableBackup) },
onDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
)
},
progressDialog = {},
errorMessage = { it.message ?: it.toString() },
onErrorDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
@@ -65,18 +59,6 @@ fun SecureBackupDisableView(
)
}
@Composable
private fun SecureBackupDisableConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_key_backup_disable_confirmation_title),
content = stringResource(id = R.string.screen_key_backup_disable_confirmation_description),
submitText = stringResource(id = R.string.screen_key_backup_disable_confirmation_action_turn_off),
destructiveSubmit = true,
onSubmitClick = onConfirm,
onDismiss = onDismiss,
)
}
@Composable
private fun ColumnScope.Buttons(
state: SecureBackupDisableState,
@@ -105,15 +87,20 @@ private fun Content(state: SecureBackupDisableState) {
@Composable
private fun SecureBackupDisableItem(text: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(color = ElementTheme.colors.bgActionSecondaryHovered)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(24.dp)
)
Text(
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
text = text,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,

View File

@@ -1,13 +0,0 @@
/*
* Copyright 2023, 2024 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.securebackup.impl.enable
sealed interface SecureBackupEnableEvents {
data object EnableBackup : SecureBackupEnableEvents
data object DismissDialog : SecureBackupEnableEvents
}

View File

@@ -1,36 +0,0 @@
/*
* Copyright 2023, 2024 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.securebackup.impl.enable
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.SessionScope
@ContributesNode(SessionScope::class)
class SecureBackupEnableNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SecureBackupEnablePresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SecureBackupEnableView(
state = state,
modifier = modifier,
onSuccess = ::navigateUp,
onBackClick = ::navigateUp,
)
}
}

View File

@@ -1,54 +0,0 @@
/*
* Copyright 2023, 2024 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.securebackup.impl.enable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.securebackup.impl.loggerTagDisable
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.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class SecureBackupEnablePresenter @Inject constructor(
private val encryptionService: EncryptionService,
) : Presenter<SecureBackupEnableState> {
@Composable
override fun present(): SecureBackupEnableState {
val enableAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: SecureBackupEnableEvents) {
when (event) {
is SecureBackupEnableEvents.EnableBackup ->
coroutineScope.enableBackup(enableAction)
SecureBackupEnableEvents.DismissDialog -> {
enableAction.value = AsyncAction.Uninitialized
}
}
}
return SecureBackupEnableState(
enableAction = enableAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.enableBackup(action: MutableState<AsyncAction<Unit>>) = launch {
suspend {
Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()")
encryptionService.enableBackups().getOrThrow()
}.runCatchingUpdatingState(action)
}
}

View File

@@ -1,15 +0,0 @@
/*
* Copyright 2023, 2024 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.securebackup.impl.enable
import io.element.android.libraries.architecture.AsyncAction
data class SecureBackupEnableState(
val enableAction: AsyncAction<Unit>,
val eventSink: (SecureBackupEnableEvents) -> Unit
)

View File

@@ -1,28 +0,0 @@
/*
* Copyright 2023, 2024 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.securebackup.impl.enable
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
open class SecureBackupEnableStateProvider : PreviewParameterProvider<SecureBackupEnableState> {
override val values: Sequence<SecureBackupEnableState>
get() = sequenceOf(
aSecureBackupEnableState(),
aSecureBackupEnableState(enableAction = AsyncAction.Loading),
aSecureBackupEnableState(enableAction = AsyncAction.Failure(Exception("Failed to enable"))),
// Add other states here
)
}
fun aSecureBackupEnableState(
enableAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = SecureBackupEnableState(
enableAction = enableAction,
eventSink = {}
)

View File

@@ -1,69 +0,0 @@
/*
* Copyright 2023, 2024 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.securebackup.impl.enable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@Composable
fun SecureBackupEnableView(
state: SecureBackupEnableState,
onSuccess: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowStepPage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
buttons = { Buttons(state = state) }
)
AsyncActionView(
async = state.enableAction,
progressDialog = { },
onSuccess = { onSuccess() },
onErrorDismiss = { state.eventSink.invoke(SecureBackupEnableEvents.DismissDialog) }
)
}
@Composable
private fun ColumnScope.Buttons(
state: SecureBackupEnableState,
) {
Button(
text = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
showProgress = state.enableAction.isLoading(),
modifier = Modifier.fillMaxWidth(),
onClick = { state.eventSink.invoke(SecureBackupEnableEvents.EnableBackup) }
)
}
@PreviewsDayNight
@Composable
internal fun SecureBackupEnableViewPreview(
@PreviewParameter(SecureBackupEnableStateProvider::class) state: SecureBackupEnableState
) = ElementPreview {
SecureBackupEnableView(
state = state,
onSuccess = {},
onBackClick = {},
)
}

View File

@@ -9,4 +9,7 @@ package io.element.android.features.securebackup.impl.root
sealed interface SecureBackupRootEvents {
data object RetryKeyBackupState : SecureBackupRootEvents
data object EnableKeyStorage : SecureBackupRootEvents
data object DisplayKeyStorageDisabledError : SecureBackupRootEvents
data object DismissDialog : SecureBackupRootEvents
}

View File

@@ -34,7 +34,6 @@ class SecureBackupRootNode @AssistedInject constructor(
fun onSetupClick()
fun onChangeClick()
fun onDisableClick()
fun onEnableClick()
fun onConfirmRecoveryKeyClick()
}
@@ -50,10 +49,6 @@ class SecureBackupRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onDisableClick() }
}
private fun onEnableClick() {
plugins<Callback>().forEach { it.onEnableClick() }
}
private fun onConfirmRecoveryKeyClick() {
plugins<Callback>().forEach { it.onConfirmRecoveryKeyClick() }
}
@@ -71,7 +66,6 @@ class SecureBackupRootNode @AssistedInject constructor(
onBackClick = ::navigateUp,
onSetupClick = ::onSetupClick,
onChangeClick = ::onChangeClick,
onEnableClick = ::onEnableClick,
onDisableClick = ::onDisableClick,
onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick,
onLearnMoreClick = { onLearnMoreClick(uriHandler) },

View File

@@ -15,7 +15,10 @@ 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 io.element.android.features.securebackup.impl.loggerTagDisable
import io.element.android.features.securebackup.impl.loggerTagRoot
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
@@ -41,7 +44,8 @@ class SecureBackupRootPresenter @Inject constructor(
val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val enableAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
var displayKeyStorageDisabledError by remember { mutableStateOf(false) }
Timber.tag(loggerTagRoot.value).d("backupState: $backupState")
Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState")
@@ -56,14 +60,22 @@ class SecureBackupRootPresenter @Inject constructor(
fun handleEvents(event: SecureBackupRootEvents) {
when (event) {
SecureBackupRootEvents.RetryKeyBackupState -> localCoroutineScope.getKeyBackupStatus(doesBackupExistOnServerAction)
SecureBackupRootEvents.EnableKeyStorage -> localCoroutineScope.enableBackup(enableAction)
SecureBackupRootEvents.DismissDialog -> {
enableAction.value = AsyncAction.Uninitialized
displayKeyStorageDisabledError = false
}
SecureBackupRootEvents.DisplayKeyStorageDisabledError -> displayKeyStorageDisabledError = true
}
}
return SecureBackupRootState(
enableAction = enableAction.value,
backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServerAction.value,
recoveryState = recoveryState,
appName = buildMeta.applicationName,
displayKeyStorageDisabledError = displayKeyStorageDisabledError,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents,
)
@@ -74,4 +86,11 @@ class SecureBackupRootPresenter @Inject constructor(
encryptionService.doesBackupExistOnServer().getOrThrow()
}.runCatchingUpdatingState(action)
}
private fun CoroutineScope.enableBackup(action: MutableState<AsyncAction<Unit>>) = launch {
suspend {
Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()")
encryptionService.enableBackups().getOrThrow()
}.runCatchingUpdatingState(action)
}
}

View File

@@ -7,16 +7,31 @@
package io.element.android.features.securebackup.impl.root
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
data class SecureBackupRootState(
val enableAction: AsyncAction<Unit>,
val backupState: BackupState,
val doesBackupExistOnServer: AsyncData<Boolean>,
val recoveryState: RecoveryState,
val appName: String,
val displayKeyStorageDisabledError: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (SecureBackupRootEvents) -> Unit,
)
) {
val isKeyStorageEnabled: Boolean
get() = when (backupState) {
BackupState.UNKNOWN -> doesBackupExistOnServer.dataOrNull() == true
BackupState.CREATING,
BackupState.ENABLING,
BackupState.RESUMING,
BackupState.DOWNLOADING,
BackupState.ENABLED -> true
BackupState.WAITING_FOR_SYNC,
BackupState.DISABLING -> false
}
}

View File

@@ -8,6 +8,7 @@
package io.element.android.features.securebackup.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.encryption.BackupState
@@ -22,28 +23,47 @@ open class SecureBackupRootStateProvider : PreviewParameterProvider<SecureBackup
aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Failure(Exception("An error"))),
aSecureBackupRootState(backupState = BackupState.WAITING_FOR_SYNC),
aSecureBackupRootState(backupState = BackupState.CREATING),
aSecureBackupRootState(
backupState = BackupState.CREATING,
enableAction = AsyncAction.Failure(Exception("Error")),
),
aSecureBackupRootState(backupState = BackupState.ENABLING),
aSecureBackupRootState(backupState = BackupState.RESUMING),
aSecureBackupRootState(backupState = BackupState.DOWNLOADING),
aSecureBackupRootState(backupState = BackupState.DISABLING),
aSecureBackupRootState(backupState = BackupState.ENABLED),
aSecureBackupRootState(recoveryState = RecoveryState.UNKNOWN),
aSecureBackupRootState(recoveryState = RecoveryState.ENABLED),
aSecureBackupRootState(recoveryState = RecoveryState.DISABLED),
aSecureBackupRootState(recoveryState = RecoveryState.INCOMPLETE),
// Add other states here
aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.UNKNOWN),
aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.ENABLED),
aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.DISABLED),
aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.INCOMPLETE),
aSecureBackupRootState(
backupState = BackupState.UNKNOWN,
doesBackupExistOnServer = AsyncData.Success(false),
recoveryState = RecoveryState.ENABLED,
),
aSecureBackupRootState(
backupState = BackupState.UNKNOWN,
doesBackupExistOnServer = AsyncData.Success(false),
recoveryState = RecoveryState.ENABLED,
displayKeyStorageDisabledError = true,
),
)
}
fun aSecureBackupRootState(
enableAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
backupState: BackupState = BackupState.UNKNOWN,
doesBackupExistOnServer: AsyncData<Boolean> = AsyncData.Uninitialized,
recoveryState: RecoveryState = RecoveryState.UNKNOWN,
displayKeyStorageDisabledError: Boolean = false,
snackbarMessage: SnackbarMessage? = null,
) = SecureBackupRootState(
enableAction = enableAction,
backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState,
appName = "Element",
displayKeyStorageDisabledError = displayKeyStorageDisabledError,
snackbarMessage = snackbarMessage,
eventSink = {},
)

View File

@@ -7,28 +7,27 @@
package io.element.android.features.securebackup.impl.root
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
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.securebackup.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.encryption.BackupState
@@ -41,7 +40,6 @@ fun SecureBackupRootView(
onBackClick: () -> Unit,
onSetupClick: () -> Unit,
onChangeClick: () -> Unit,
onEnableClick: () -> Unit,
onDisableClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onLearnMoreClick: () -> Unit,
@@ -52,122 +50,186 @@ fun SecureBackupRootView(
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_chat_backup),
title = stringResource(id = CommonStrings.common_encryption),
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
val text = buildAnnotatedStringWithStyledPart(
fullTextRes = R.string.screen_chat_backup_key_backup_description,
coloredTextRes = CommonStrings.action_learn_more,
color = ElementTheme.colors.textPrimary,
underline = false,
bold = true,
)
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_title),
subtitleAnnotated = text,
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_key_backup_title),
)
},
supportingContent = {
Text(
text = buildAnnotatedStringWithStyledPart(
fullTextRes = R.string.screen_chat_backup_key_backup_description,
coloredTextRes = CommonStrings.action_learn_more,
color = ElementTheme.colors.textPrimary,
underline = false,
bold = true,
),
)
},
onClick = onLearnMoreClick,
)
// Disable / Enable backup
when (state.backupState) {
BackupState.WAITING_FOR_SYNC -> Unit
BackupState.UNKNOWN -> {
when (state.doesBackupExistOnServer) {
is AsyncData.Success -> when (state.doesBackupExistOnServer.data) {
true -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable),
tintColor = ElementTheme.colors.textCriticalPrimary,
onClick = onDisableClick,
// Disable / Enable key storage
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_key_storage_toggle_title),
)
},
trailingContent = when (state.backupState) {
BackupState.WAITING_FOR_SYNC,
BackupState.DISABLING -> ListItemContent.Custom { LoadingView() }
BackupState.UNKNOWN -> {
when (state.doesBackupExistOnServer) {
is AsyncData.Success -> {
ListItemContent.Switch(checked = state.doesBackupExistOnServer.data)
}
is AsyncData.Loading,
AsyncData.Uninitialized -> ListItemContent.Custom { LoadingView() }
is AsyncData.Failure -> ListItemContent.Custom {
Text(
text = stringResource(id = CommonStrings.action_retry)
)
}
false -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
onClick = onEnableClick,
)
}
}
is AsyncData.Loading,
AsyncData.Uninitialized -> {
ListItem(headlineContent = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
})
}
is AsyncData.Failure -> {
ListItem(
headlineContent = {
Text(
text = stringResource(id = CommonStrings.error_unknown),
)
},
trailingContent = ListItemContent.Custom {
TextButton(
text = stringResource(
id = CommonStrings.action_retry
),
onClick = { state.eventSink.invoke(SecureBackupRootEvents.RetryKeyBackupState) }
)
}
)
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
onClick = onEnableClick,
)
}
}
}
BackupState.CREATING,
BackupState.ENABLING,
BackupState.RESUMING,
BackupState.ENABLED,
BackupState.DOWNLOADING -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable),
tintColor = ElementTheme.colors.textCriticalPrimary,
onClick = onDisableClick,
)
}
BackupState.DISABLING -> {
AsyncLoading()
}
}
PreferenceDivider()
BackupState.CREATING,
BackupState.ENABLING,
BackupState.RESUMING,
BackupState.ENABLED,
BackupState.DOWNLOADING -> ListItemContent.Switch(checked = true)
},
onClick = {
when (state.backupState) {
BackupState.WAITING_FOR_SYNC,
BackupState.DISABLING -> Unit
BackupState.UNKNOWN -> {
when (state.doesBackupExistOnServer) {
is AsyncData.Success -> {
if (state.doesBackupExistOnServer.data) {
onDisableClick()
} else {
state.eventSink.invoke(SecureBackupRootEvents.EnableKeyStorage)
}
}
is AsyncData.Loading,
AsyncData.Uninitialized -> Unit
is AsyncData.Failure -> state.eventSink.invoke(SecureBackupRootEvents.RetryKeyBackupState)
}
}
BackupState.CREATING,
BackupState.ENABLING,
BackupState.RESUMING,
BackupState.ENABLED,
BackupState.DOWNLOADING -> onDisableClick()
}
},
)
HorizontalDivider()
// Setup recovery
when (state.recoveryState) {
RecoveryState.UNKNOWN,
RecoveryState.WAITING_FOR_SYNC -> Unit
RecoveryState.DISABLED -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName),
onClick = onSetupClick,
showEndBadge = true,
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
)
},
supportingContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName),
)
},
trailingContent = ListItemContent.Badge,
enabled = state.isKeyStorageEnabled,
alwaysClickable = true,
onClick = {
if (state.isKeyStorageEnabled) {
onSetupClick()
} else {
state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
}
},
)
}
RecoveryState.ENABLED -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_change),
onClick = onChangeClick,
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_change),
)
},
supportingContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_change_description),
)
},
enabled = state.isKeyStorageEnabled,
alwaysClickable = true,
onClick = {
if (state.isKeyStorageEnabled) {
onChangeClick()
} else {
state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
}
},
)
}
RecoveryState.INCOMPLETE ->
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description),
showEndBadge = true,
onClick = onConfirmRecoveryKeyClick,
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
)
},
supportingContent = {
Text(
text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description),
)
},
trailingContent = ListItemContent.Badge,
enabled = state.isKeyStorageEnabled,
alwaysClickable = true,
onClick = {
if (state.isKeyStorageEnabled) {
onConfirmRecoveryKeyClick()
} else {
state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError)
}
},
)
}
}
AsyncActionView(
async = state.enableAction,
progressDialog = { },
onSuccess = { },
onErrorDismiss = { state.eventSink.invoke(SecureBackupRootEvents.DismissDialog) }
)
if (state.displayKeyStorageDisabledError) {
ErrorDialog(
title = null,
content = stringResource(id = R.string.screen_chat_backup_key_storage_disabled_error),
onSubmit = { state.eventSink.invoke(SecureBackupRootEvents.DismissDialog) },
)
}
}
@Composable
private fun LoadingView() {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(24.dp),
strokeWidth = 2.dp
)
}
@PreviewsDayNight
@@ -180,7 +242,6 @@ internal fun SecureBackupRootViewPreview(
onBackClick = {},
onSetupClick = {},
onChangeClick = {},
onEnableClick = {},
onDisableClick = {},
onConfirmRecoveryKeyClick = {},
onLearnMoreClick = {},

View File

@@ -91,14 +91,14 @@ private fun RecoveryKeyStaticContent(
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(14.dp)
)
.clickableIfNotNull(onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(14.dp)
)
.clickableIfNotNull(onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (state.formattedRecoveryKey != null) {
@@ -116,15 +116,15 @@ private fun RecoveryKeyStaticContent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 11.dp)
.fillMaxWidth()
.padding(vertical = 11.dp)
) {
if (state.inProgress) {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.padding(end = 8.dp)
.size(16.dp),
.progressSemantics()
.padding(end = 8.dp)
.size(16.dp),
color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp,
)
@@ -161,12 +161,12 @@ private fun RecoveryKeyFormContent(
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.recoveryKey)
.autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = { onChange(it) },
),
.fillMaxWidth()
.testTag(TestTags.recoveryKey)
.autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = { onChange(it) },
),
minLines = 2,
value = state.formattedRecoveryKey.orEmpty(),
onValueChange = onChange,
@@ -189,30 +189,18 @@ private fun RecoveryKeyFooter(state: RecoveryKeyViewState) {
RecoveryKeyUserStory.Setup,
RecoveryKeyUserStory.Change -> {
if (state.formattedRecoveryKey == null) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = CompoundIcons.InfoSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
modifier = Modifier
.padding(start = 16.dp)
.size(20.dp),
)
Text(
text = stringResource(
id = if (state.recoveryKeyUserStory == RecoveryKeyUserStory.Change) {
R.string.screen_recovery_key_change_generate_key_description
} else {
R.string.screen_recovery_key_setup_generate_key_description
}
),
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(start = 8.dp),
style = ElementTheme.typography.fontBodySmRegular,
)
}
Text(
text = stringResource(
id = if (state.recoveryKeyUserStory == RecoveryKeyUserStory.Change) {
R.string.screen_recovery_key_change_generate_key_description
} else {
R.string.screen_recovery_key_setup_generate_key_description
}
),
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(start = 16.dp),
style = ElementTheme.typography.fontBodySmRegular,
)
} else {
Text(
text = stringResource(id = R.string.screen_recovery_key_save_key_description),

View File

@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Turn off backup"</string>
<string name="screen_chat_backup_key_backup_action_disable">"Delete key storage"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Turn on backup"</string>
<string name="screen_chat_backup_key_backup_description">"Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Key storage"</string>
<string name="screen_chat_backup_key_storage_disabled_error">"Key storage must be turned on to set up recovery."</string>
<string name="screen_chat_backup_key_storage_toggle_description">"Upload keys from this device"</string>
<string name="screen_chat_backup_key_storage_toggle_title">"Allow key storage"</string>
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</string>
@@ -28,10 +29,10 @@
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_key_backup_disable_description">"Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:"</string>
<string name="screen_key_backup_disable_description_point_1">"Not have encrypted message history on new devices"</string>
<string name="screen_key_backup_disable_description_point_2">"Lose access to your encrypted messages if you are signed out of %1$s everywhere"</string>
<string name="screen_key_backup_disable_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_key_backup_disable_description">"Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features:"</string>
<string name="screen_key_backup_disable_description_point_1">"You will not have encrypted message history on new devices"</string>
<string name="screen_key_backup_disable_description_point_2">"You will lose access to your encrypted messages if you are signed out of %1$s everywhere"</string>
<string name="screen_key_backup_disable_title">"Are you sure you want to turn off key storage and delete it?"</string>
<string name="screen_recovery_key_change_description">"Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work."</string>
<string name="screen_recovery_key_change_generate_key">"Generate a new recovery key"</string>
<string name="screen_recovery_key_change_generate_key_description">"Do not share this with anyone!"</string>
@@ -54,7 +55,7 @@
<string name="screen_recovery_key_save_title">"Save your recovery key somewhere safe"</string>
<string name="screen_recovery_key_setup_confirmation_description">"You will not be able to access your new recovery key after this step."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Have you saved your recovery key?"</string>
<string name="screen_recovery_key_setup_description">"Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting Change recovery key."</string>
<string name="screen_recovery_key_setup_description">"Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting Change recovery key."</string>
<string name="screen_recovery_key_setup_generate_key">"Generate your recovery key"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Do not share this with anyone!"</string>
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</string>

View File

@@ -38,22 +38,6 @@ class SecureBackupDisablePresenterTest {
}
}
@Test
fun `present - user delete backup and cancel`() = runTest {
val presenter = createSecureBackupDisablePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val state = awaitItem()
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(SecureBackupDisableEvents.DismissDialogs)
val finalState = awaitItem()
assertThat(finalState.disableAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - user delete backup success`() = runTest {
val presenter = createSecureBackupDisablePresenter()
@@ -63,9 +47,6 @@ class SecureBackupDisablePresenterTest {
val initialState = awaitItem()
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val state = awaitItem()
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val loadingState = awaitItem()
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
val finalState = awaitItem()
@@ -87,9 +68,6 @@ class SecureBackupDisablePresenterTest {
val initialState = awaitItem()
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val state = awaitItem()
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val loadingState = awaitItem()
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem()

View File

@@ -1,78 +0,0 @@
/*
* Copyright 2023, 2024 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.securebackup.impl.enable
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SecureBackupEnablePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.enableAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - user enable backup`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SecureBackupEnableEvents.EnableBackup)
val loadingState = awaitItem()
assertThat(loadingState.enableAction).isInstanceOf(AsyncAction.Loading::class.java)
val finalState = awaitItem()
assertThat(finalState.enableAction).isEqualTo(AsyncAction.Success(Unit))
}
}
@Test
fun `present - user enable backup with error`() = runTest {
val encryptionService = FakeEncryptionService()
encryptionService.givenEnableBackupsFailure(AN_EXCEPTION)
val presenter = createPresenter(encryptionService = encryptionService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SecureBackupEnableEvents.EnableBackup)
val loadingState = awaitItem()
assertThat(loadingState.enableAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem()
assertThat(errorState.enableAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink(SecureBackupEnableEvents.DismissDialog)
val finalState = awaitItem()
assertThat(finalState.enableAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun createPresenter(
encryptionService: EncryptionService = FakeEncryptionService(),
) = SecureBackupEnablePresenter(
encryptionService = encryptionService,
)
}