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() { override fun onSetUpRecoveryClick() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.SetUpRecovery)) backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
} }
override fun onSessionConfirmRecoveryKeyClick() { override fun onSessionConfirmRecoveryKeyClick() {

View file

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

View file

@ -25,6 +25,7 @@ internal fun SetUpRecoveryKeyBanner(
modifier = modifier, modifier = modifier,
title = stringResource(R.string.banner_set_up_recovery_title), title = stringResource(R.string.banner_set_up_recovery_title),
content = stringResource(R.string.banner_set_up_recovery_content), content = stringResource(R.string.banner_set_up_recovery_content),
actionText = stringResource(R.string.banner_set_up_recovery_submit),
onSubmitClick = onContinueClick, onSubmitClick = onContinueClick,
onDismissClick = onDismissClick, 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_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_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_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_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_title">"Set up recovery"</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_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="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> <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, onSetUpRecoveryClick = callback,
) )
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.clickOn(R.string.banner_set_up_recovery_submit)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
} }
} }

View file

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

View file

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

View file

@ -7,6 +7,7 @@
package io.element.android.features.securebackup.impl.disable package io.element.android.features.securebackup.impl.disable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope 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.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -44,7 +44,7 @@ fun SecureBackupDisableView(
onBackClick = onBackClick, onBackClick = onBackClick,
title = stringResource(id = R.string.screen_key_backup_disable_title), title = stringResource(id = R.string.screen_key_backup_disable_title),
subTitle = stringResource(id = R.string.screen_key_backup_disable_description), subTitle = stringResource(id = R.string.screen_key_backup_disable_description),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeyOffSolid()), iconStyle = BigIcon.Style.AlertSolid,
buttons = { Buttons(state = state) }, buttons = { Buttons(state = state) },
) { ) {
Content(state = state) Content(state = state)
@ -52,12 +52,6 @@ fun SecureBackupDisableView(
AsyncActionView( AsyncActionView(
async = state.disableAction, async = state.disableAction,
confirmationDialog = {
SecureBackupDisableConfirmationDialog(
onConfirm = { state.eventSink.invoke(SecureBackupDisableEvents.DisableBackup) },
onDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
)
},
progressDialog = {}, progressDialog = {},
errorMessage = { it.message ?: it.toString() }, errorMessage = { it.message ?: it.toString() },
onErrorDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) }, 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 @Composable
private fun ColumnScope.Buttons( private fun ColumnScope.Buttons(
state: SecureBackupDisableState, state: SecureBackupDisableState,
@ -105,15 +87,20 @@ private fun Content(state: SecureBackupDisableState) {
@Composable @Composable
private fun SecureBackupDisableItem(text: String) { 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( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = null, contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary, tint = ElementTheme.colors.iconCriticalPrimary,
modifier = Modifier.size(20.dp) modifier = Modifier.size(24.dp)
) )
Text( Text(
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
text = text, text = text,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular, 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 { sealed interface SecureBackupRootEvents {
data object RetryKeyBackupState : 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 onSetupClick()
fun onChangeClick() fun onChangeClick()
fun onDisableClick() fun onDisableClick()
fun onEnableClick()
fun onConfirmRecoveryKeyClick() fun onConfirmRecoveryKeyClick()
} }
@ -50,10 +49,6 @@ class SecureBackupRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onDisableClick() } plugins<Callback>().forEach { it.onDisableClick() }
} }
private fun onEnableClick() {
plugins<Callback>().forEach { it.onEnableClick() }
}
private fun onConfirmRecoveryKeyClick() { private fun onConfirmRecoveryKeyClick() {
plugins<Callback>().forEach { it.onConfirmRecoveryKeyClick() } plugins<Callback>().forEach { it.onConfirmRecoveryKeyClick() }
} }
@ -71,7 +66,6 @@ class SecureBackupRootNode @AssistedInject constructor(
onBackClick = ::navigateUp, onBackClick = ::navigateUp,
onSetupClick = ::onSetupClick, onSetupClick = ::onSetupClick,
onChangeClick = ::onChangeClick, onChangeClick = ::onChangeClick,
onEnableClick = ::onEnableClick,
onDisableClick = ::onDisableClick, onDisableClick = ::onDisableClick,
onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick,
onLearnMoreClick = { onLearnMoreClick(uriHandler) }, onLearnMoreClick = { onLearnMoreClick(uriHandler) },

View file

@ -15,7 +15,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.features.securebackup.impl.loggerTagRoot
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -41,7 +44,8 @@ class SecureBackupRootPresenter @Inject constructor(
val backupState by encryptionService.backupStateStateFlow.collectAsState() val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.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("backupState: $backupState")
Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState") Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState")
@ -56,14 +60,22 @@ class SecureBackupRootPresenter @Inject constructor(
fun handleEvents(event: SecureBackupRootEvents) { fun handleEvents(event: SecureBackupRootEvents) {
when (event) { when (event) {
SecureBackupRootEvents.RetryKeyBackupState -> localCoroutineScope.getKeyBackupStatus(doesBackupExistOnServerAction) SecureBackupRootEvents.RetryKeyBackupState -> localCoroutineScope.getKeyBackupStatus(doesBackupExistOnServerAction)
SecureBackupRootEvents.EnableKeyStorage -> localCoroutineScope.enableBackup(enableAction)
SecureBackupRootEvents.DismissDialog -> {
enableAction.value = AsyncAction.Uninitialized
displayKeyStorageDisabledError = false
}
SecureBackupRootEvents.DisplayKeyStorageDisabledError -> displayKeyStorageDisabledError = true
} }
} }
return SecureBackupRootState( return SecureBackupRootState(
enableAction = enableAction.value,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServerAction.value, doesBackupExistOnServer = doesBackupExistOnServerAction.value,
recoveryState = recoveryState, recoveryState = recoveryState,
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
displayKeyStorageDisabledError = displayKeyStorageDisabledError,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
@ -74,4 +86,11 @@ class SecureBackupRootPresenter @Inject constructor(
encryptionService.doesBackupExistOnServer().getOrThrow() encryptionService.doesBackupExistOnServer().getOrThrow()
}.runCatchingUpdatingState(action) }.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 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.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage 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.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
data class SecureBackupRootState( data class SecureBackupRootState(
val enableAction: AsyncAction<Unit>,
val backupState: BackupState, val backupState: BackupState,
val doesBackupExistOnServer: AsyncData<Boolean>, val doesBackupExistOnServer: AsyncData<Boolean>,
val recoveryState: RecoveryState, val recoveryState: RecoveryState,
val appName: String, val appName: String,
val displayKeyStorageDisabledError: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val eventSink: (SecureBackupRootEvents) -> Unit, 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 package io.element.android.features.securebackup.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage 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.BackupState
@ -22,28 +23,47 @@ open class SecureBackupRootStateProvider : PreviewParameterProvider<SecureBackup
aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Failure(Exception("An error"))), aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Failure(Exception("An error"))),
aSecureBackupRootState(backupState = BackupState.WAITING_FOR_SYNC), aSecureBackupRootState(backupState = BackupState.WAITING_FOR_SYNC),
aSecureBackupRootState(backupState = BackupState.CREATING), aSecureBackupRootState(backupState = BackupState.CREATING),
aSecureBackupRootState(
backupState = BackupState.CREATING,
enableAction = AsyncAction.Failure(Exception("Error")),
),
aSecureBackupRootState(backupState = BackupState.ENABLING),
aSecureBackupRootState(backupState = BackupState.RESUMING), aSecureBackupRootState(backupState = BackupState.RESUMING),
aSecureBackupRootState(backupState = BackupState.DOWNLOADING), aSecureBackupRootState(backupState = BackupState.DOWNLOADING),
aSecureBackupRootState(backupState = BackupState.DISABLING), aSecureBackupRootState(backupState = BackupState.DISABLING),
aSecureBackupRootState(backupState = BackupState.ENABLED), aSecureBackupRootState(backupState = BackupState.ENABLED),
aSecureBackupRootState(recoveryState = RecoveryState.UNKNOWN), aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.UNKNOWN),
aSecureBackupRootState(recoveryState = RecoveryState.ENABLED), aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.ENABLED),
aSecureBackupRootState(recoveryState = RecoveryState.DISABLED), aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.DISABLED),
aSecureBackupRootState(recoveryState = RecoveryState.INCOMPLETE), aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.INCOMPLETE),
// Add other states here 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( fun aSecureBackupRootState(
enableAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
backupState: BackupState = BackupState.UNKNOWN, backupState: BackupState = BackupState.UNKNOWN,
doesBackupExistOnServer: AsyncData<Boolean> = AsyncData.Uninitialized, doesBackupExistOnServer: AsyncData<Boolean> = AsyncData.Uninitialized,
recoveryState: RecoveryState = RecoveryState.UNKNOWN, recoveryState: RecoveryState = RecoveryState.UNKNOWN,
displayKeyStorageDisabledError: Boolean = false,
snackbarMessage: SnackbarMessage? = null, snackbarMessage: SnackbarMessage? = null,
) = SecureBackupRootState( ) = SecureBackupRootState(
enableAction = enableAction,
backupState = backupState, backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer, doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState, recoveryState = recoveryState,
appName = "Element", appName = "Element",
displayKeyStorageDisabledError = displayKeyStorageDisabledError,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
eventSink = {}, eventSink = {},
) )

View file

@ -7,28 +7,27 @@
package io.element.android.features.securebackup.impl.root package io.element.android.features.securebackup.impl.root
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter 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.theme.ElementTheme
import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.architecture.AsyncData 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.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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart 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.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.ListItem
import io.element.android.libraries.designsystem.theme.components.Text 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.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
@ -41,7 +40,6 @@ fun SecureBackupRootView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSetupClick: () -> Unit, onSetupClick: () -> Unit,
onChangeClick: () -> Unit, onChangeClick: () -> Unit,
onEnableClick: () -> Unit,
onDisableClick: () -> Unit, onDisableClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onLearnMoreClick: () -> Unit, onLearnMoreClick: () -> Unit,
@ -52,122 +50,186 @@ fun SecureBackupRootView(
PreferencePage( PreferencePage(
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_chat_backup), title = stringResource(id = CommonStrings.common_encryption),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { ) {
val text = buildAnnotatedStringWithStyledPart( ListItem(
fullTextRes = R.string.screen_chat_backup_key_backup_description, headlineContent = {
coloredTextRes = CommonStrings.action_learn_more, Text(
color = ElementTheme.colors.textPrimary, text = stringResource(id = R.string.screen_chat_backup_key_backup_title),
underline = false, )
bold = true, },
) supportingContent = {
PreferenceText( Text(
title = stringResource(id = R.string.screen_chat_backup_key_backup_title), text = buildAnnotatedStringWithStyledPart(
subtitleAnnotated = text, fullTextRes = R.string.screen_chat_backup_key_backup_description,
coloredTextRes = CommonStrings.action_learn_more,
color = ElementTheme.colors.textPrimary,
underline = false,
bold = true,
),
)
},
onClick = onLearnMoreClick, onClick = onLearnMoreClick,
) )
// Disable / Enable backup // Disable / Enable key storage
when (state.backupState) { ListItem(
BackupState.WAITING_FOR_SYNC -> Unit headlineContent = {
BackupState.UNKNOWN -> { Text(
when (state.doesBackupExistOnServer) { text = stringResource(id = R.string.screen_chat_backup_key_storage_toggle_title),
is AsyncData.Success -> when (state.doesBackupExistOnServer.data) { )
true -> { },
PreferenceText( trailingContent = when (state.backupState) {
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), BackupState.WAITING_FOR_SYNC,
tintColor = ElementTheme.colors.textCriticalPrimary, BackupState.DISABLING -> ListItemContent.Custom { LoadingView() }
onClick = onDisableClick, 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.CREATING, BackupState.ENABLING,
BackupState.ENABLING, BackupState.RESUMING,
BackupState.RESUMING, BackupState.ENABLED,
BackupState.ENABLED, BackupState.DOWNLOADING -> ListItemContent.Switch(checked = true)
BackupState.DOWNLOADING -> { },
PreferenceText( onClick = {
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), when (state.backupState) {
tintColor = ElementTheme.colors.textCriticalPrimary, BackupState.WAITING_FOR_SYNC,
onClick = onDisableClick, BackupState.DISABLING -> Unit
) BackupState.UNKNOWN -> {
} when (state.doesBackupExistOnServer) {
BackupState.DISABLING -> { is AsyncData.Success -> {
AsyncLoading() if (state.doesBackupExistOnServer.data) {
} onDisableClick()
} } else {
state.eventSink.invoke(SecureBackupRootEvents.EnableKeyStorage)
PreferenceDivider() }
}
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 // Setup recovery
when (state.recoveryState) { when (state.recoveryState) {
RecoveryState.UNKNOWN, RecoveryState.UNKNOWN,
RecoveryState.WAITING_FOR_SYNC -> Unit RecoveryState.WAITING_FOR_SYNC -> Unit
RecoveryState.DISABLED -> { RecoveryState.DISABLED -> {
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_setup), headlineContent = {
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName), Text(
onClick = onSetupClick, text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
showEndBadge = true, )
},
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 -> { RecoveryState.ENABLED -> {
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_change), headlineContent = {
onClick = onChangeClick, 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 -> RecoveryState.INCOMPLETE ->
PreferenceText( ListItem(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm), headlineContent = {
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description), Text(
showEndBadge = true, text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
onClick = onConfirmRecoveryKeyClick, )
},
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 @PreviewsDayNight
@ -180,7 +242,6 @@ internal fun SecureBackupRootViewPreview(
onBackClick = {}, onBackClick = {},
onSetupClick = {}, onSetupClick = {},
onChangeClick = {}, onChangeClick = {},
onEnableClick = {},
onDisableClick = {}, onDisableClick = {},
onConfirmRecoveryKeyClick = {}, onConfirmRecoveryKeyClick = {},
onLearnMoreClick = {}, onLearnMoreClick = {},

View file

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

View file

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_key_storage_toggle_title">"Allow key storage"</string>
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</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_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_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_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">"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">"Not have encrypted message history on new devices"</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">"Lose access to your encrypted messages if you are signed out of %1$s everywhere"</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 backup?"</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_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">"Generate a new recovery key"</string>
<string name="screen_recovery_key_change_generate_key_description">"Do not share this with anyone!"</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_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_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_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">"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_generate_key_description">"Do not share this with anyone!"</string>
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</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 @Test
fun `present - user delete backup success`() = runTest { fun `present - user delete backup success`() = runTest {
val presenter = createSecureBackupDisablePresenter() val presenter = createSecureBackupDisablePresenter()
@ -63,9 +47,6 @@ class SecureBackupDisablePresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup) initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val state = awaitItem()
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
val finalState = awaitItem() val finalState = awaitItem()
@ -87,9 +68,6 @@ class SecureBackupDisablePresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup) initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val state = awaitItem()
assertThat(state.disableAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(SecureBackupDisableEvents.DisableBackup)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java)
val errorState = awaitItem() 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,
)
}