Remove custom recovery passphrase flow from FOSS secure backup setup

The custom passphrase entry/confirm UI now lives in the enterprise secure backup module behind the SecureBackupSetupEntryPoint seam. Reverts the setup presenter, view, state, state machine, state provider, and events to the auto-generated-key-only flow, deletes CustomPassphraseDerivations and the temporary strings, and drops the well-known and enterprise-test dependencies the feature required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-06-08 15:36:02 -07:00
parent 1f84bef601
commit a050716123
10 changed files with 70 additions and 1545 deletions
@@ -38,12 +38,9 @@ dependencies {
implementation(projects.libraries.oauth.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.libraries.wellknown.api)
api(libs.statemachine)
api(projects.features.securebackup.api)
testCommonDependencies(libs, true)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.wellknown.test)
}
@@ -1,51 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.securebackup.impl.setup
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
/**
* Validation flags computed once and shared by the presenter and the preview provider.
*
* Note: passphrase strength is intentionally NOT derived here. The estimation algorithm is an
* enterprise capability ([io.element.android.features.enterprise.api.EnterpriseService.estimateCustomRecoveryPassphraseStrength]);
* the presenter computes it via the injected service and the state provider supplies samples directly.
*/
internal data class CustomPassphraseDerivations(
val meetsMinLength: Boolean,
val mismatch: Boolean,
val canContinueFromEntry: Boolean,
val canSubmitCustomPassphrase: Boolean,
)
internal fun deriveCustomPassphraseState(
requirements: CustomRecoveryPassphraseRequirements?,
passphrase: String,
confirm: String,
step: CustomEntryStep,
setupState: SetupState,
): CustomPassphraseDerivations {
val meetsMinLength = requirements?.isSatisfiedBy(passphrase) ?: true
val mismatch = confirm.isNotEmpty() && passphrase != confirm
val canContinueFromEntry = requirements != null &&
passphrase.isNotEmpty() &&
meetsMinLength &&
setupState is SetupState.Init
val canSubmit = requirements != null &&
passphrase.isNotEmpty() &&
passphrase == confirm &&
meetsMinLength &&
step == CustomEntryStep.Confirm &&
setupState is SetupState.Init
return CustomPassphraseDerivations(
meetsMinLength = meetsMinLength,
mismatch = mismatch,
canContinueFromEntry = canContinueFromEntry,
canSubmitCustomPassphrase = canSubmit,
)
}
@@ -13,22 +13,4 @@ sealed interface SecureBackupSetupEvents {
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents
data object Done : SecureBackupSetupEvents
data object DismissDialog : SecureBackupSetupEvents
/** Update the user-typed custom recovery passphrase. */
data class UpdateCustomPassphrase(val value: String) : SecureBackupSetupEvents
/** Update the user-typed confirmation field. */
data class UpdateCustomPassphraseConfirm(val value: String) : SecureBackupSetupEvents
/** Advance from the Entry step to the Confirm step. No-op unless the entry passphrase meets requirements. */
data object ContinueCustomPassphrase : SecureBackupSetupEvents
/** Step back from Confirm to Entry. Both typed values are preserved. */
data object BackToCustomEntry : SecureBackupSetupEvents
/** Submit the custom passphrase to the SDK (only valid when [SecureBackupSetupState.canSubmitCustomPassphrase]). */
data object SubmitCustomPassphrase : SecureBackupSetupEvents
/** Abort an in-flight custom submit (back press while creating) and return to the Confirm step. */
data object CancelCustomPassphraseSubmit : SecureBackupSetupEvents
}
@@ -11,7 +11,6 @@
package io.element.android.features.securebackup.impl.setup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -23,33 +22,22 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.securebackup.impl.loggerTagSetup
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
private data class WellknownStatus(
val loaded: Boolean,
val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?,
)
@AssistedInject
class SecureBackupSetupPresenter(
@Assisted private val isChangeRecoveryKeyUserStory: Boolean,
private val stateMachine: SecureBackupSetupStateMachine,
private val encryptionService: EncryptionService,
private val sessionWellknownRetriever: SessionWellknownRetriever,
private val enterpriseService: EnterpriseService,
) : Presenter<SecureBackupSetupState> {
@AssistedFactory
interface Factory {
@@ -65,81 +53,10 @@ class SecureBackupSetupPresenter(
}
var showSaveConfirmationDialog by remember { mutableStateOf(false) }
// Spinner until the well-known fetch settles, so the user can't start the auto-gen path
// while a custom spec is still in flight. The loaded/specs pair flips in one assignment.
val wellknownStatusState = remember {
mutableStateOf(WellknownStatus(loaded = false, customRecoveryPassphraseRequirements = null))
}
val wellknownLoaded by remember {
derivedStateOf { wellknownStatusState.value.loaded }
}
val customRecoveryPassphraseRequirements by remember {
derivedStateOf { wellknownStatusState.value.customRecoveryPassphraseRequirements }
}
// Not rememberSaveable: the passphrase must never reach the on-disk saved-state bundle.
// Plain String (not a zeroed CharArray): the TextField/event/SDK boundary all copy Strings
// anyway, so we just clear it on success and accept loss on process death.
var customPassphrase by remember { mutableStateOf("") }
var customPassphraseConfirm by remember { mutableStateOf("") }
var customEntryStep by remember { mutableStateOf(CustomEntryStep.Entry) }
// Single-flight guard: a button tap and an IME-Done in the same frame both see
// canSubmit=true and would each launch an enableRecovery coroutine. The state machine
// dedupes UserCreatesKey, but only this flag stops the duplicate SDK call.
var customSubmitInFlight by remember { mutableStateOf(false) }
// Handle to the in-flight custom submit so a back press can abort it (see CancelCustomPassphraseSubmit).
var customSubmitJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(setupState) {
if (setupState !is SetupState.Creating) {
customSubmitInFlight = false
}
}
LaunchedEffect(Unit) {
val result = sessionWellknownRetriever.getElementWellKnown()
// Enterprise gate: even if the homeserver advertises a custom spec, only honor it on
// builds where the feature is enabled. FOSS builds always fall back to the auto-gen path.
val specsFromWellknown = (result as? WellknownRetrieverResult.Success)?.data?.customRecoveryPassphraseRequirements
wellknownStatusState.value = WellknownStatus(
loaded = true,
customRecoveryPassphraseRequirements = specsFromWellknown.takeIf { enterpriseService.isCustomRecoveryPassphraseEnabled() },
)
}
// Shared with SecureBackupSetupStateProvider so previews never drift from runtime.
val derivations by remember {
derivedStateOf {
deriveCustomPassphraseState(
requirements = wellknownStatusState.value.customRecoveryPassphraseRequirements,
passphrase = customPassphrase,
confirm = customPassphraseConfirm,
step = customEntryStep,
setupState = setupState,
)
}
}
// Strength is an enterprise-only estimation; null while the field is empty or in FOSS builds.
// Only computed on the Entry step, the only place the indicator is rendered.
val customPassphraseStrength by remember {
derivedStateOf {
customPassphrase
.takeIf { customEntryStep == CustomEntryStep.Entry && it.isNotEmpty() }
?.let { enterpriseService.estimateCustomRecoveryPassphraseStrength(it) }
}
}
// Nothing to "save" when the user chose the passphrase: auto-skip the Created step.
LaunchedEffect(setupState, customRecoveryPassphraseRequirements) {
if (customRecoveryPassphraseRequirements != null && setupState is SetupState.Created) {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
}
}
fun handleEvent(event: SecureBackupSetupEvents) {
when (event) {
SecureBackupSetupEvents.CreateRecoveryKey -> {
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch, passphrase = null)
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch)
}
SecureBackupSetupEvents.RecoveryKeyHasBeenSaved ->
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
@@ -150,51 +67,12 @@ class SecureBackupSetupPresenter(
SecureBackupSetupEvents.Done -> {
showSaveConfirmationDialog = true
}
is SecureBackupSetupEvents.UpdateCustomPassphrase -> {
customPassphrase = event.value
}
is SecureBackupSetupEvents.UpdateCustomPassphraseConfirm -> {
customPassphraseConfirm = event.value
}
SecureBackupSetupEvents.ContinueCustomPassphrase -> {
if (derivations.canContinueFromEntry) {
customEntryStep = CustomEntryStep.Confirm
}
}
SecureBackupSetupEvents.BackToCustomEntry -> {
customEntryStep = CustomEntryStep.Entry
}
SecureBackupSetupEvents.SubmitCustomPassphrase -> {
if (derivations.canSubmitCustomPassphrase && !customSubmitInFlight) {
customSubmitInFlight = true
customSubmitJob = coroutineScope.createOrChangeRecoveryKey(
stateAndDispatch,
passphrase = customPassphrase,
onSuccess = {
customPassphrase = ""
customPassphraseConfirm = ""
},
)
}
}
SecureBackupSetupEvents.CancelCustomPassphraseSubmit -> {
// Abort the SDK call and snap back to Initial. Dispatch the reset first so that
// if the (now cancelled) coroutine still manages to dispatch SdkError/
// SdkHasCreatedKey, those land in Initial where the state machine ignores them.
customSubmitJob?.cancel()
customSubmitJob = null
customSubmitInFlight = false
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCancelledCreate)
}
}
}
val isCustomFlow = customRecoveryPassphraseRequirements != null
val recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
// Custom flow: never surface the SDK base58 key in view state, even during the
// brief Created/CreatedAndSaved auto-skip window.
formattedRecoveryKey = if (isCustomFlow) null else setupState.recoveryKey(),
formattedRecoveryKey = setupState.recoveryKey(),
displayTextFieldContents = true,
inProgress = setupState is SetupState.Creating,
)
@@ -204,16 +82,6 @@ class SecureBackupSetupPresenter(
recoveryKeyViewState = recoveryKeyViewState,
setupState = setupState,
showSaveConfirmationDialog = showSaveConfirmationDialog,
wellknownLoaded = wellknownLoaded,
customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements,
customEntryStep = customEntryStep,
customPassphrase = customPassphrase,
customPassphraseConfirm = customPassphraseConfirm,
customPassphraseMeetsMinLength = derivations.meetsMinLength,
customPassphraseMismatch = derivations.mismatch,
customPassphraseStrength = customPassphraseStrength,
canContinueFromEntry = derivations.canContinueFromEntry,
canSubmitCustomPassphrase = derivations.canSubmitCustomPassphrase,
eventSink = ::handleEvent,
)
}
@@ -230,43 +98,47 @@ class SecureBackupSetupPresenter(
}
private fun CoroutineScope.createOrChangeRecoveryKey(
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>,
passphrase: String?,
onSuccess: () -> Unit = {},
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>
) = launch {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey)
// Custom passphrase → enableRecovery(passphrase): the SDK derives the 4S key from it.
// For the Change flow this rotates in place — the SDK skips backup creation when recovery
// is already enabled (confirmed), so there's no key-backup teardown or room-key re-upload.
val result = if (passphrase != null) {
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=present)")
encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = passphrase)
} else if (isChangeRecoveryKeyUserStory) {
if (isChangeRecoveryKeyUserStory) {
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()")
encryptionService.resetRecoveryKey()
encryptionService.resetRecoveryKey().fold(
onSuccess = {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it))
},
onFailure = {
if (it is Exception) {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
}
}
)
} else {
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=absent)")
encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = null)
}
result.fold(
onSuccess = { key ->
// Clear buffers only on success; on failure keep them so the user can retry
// without retyping.
onSuccess()
// Custom flow: scrub the SDK base58 key from the state machine so SetupState
// never carries it. RustEncryptionService likewise keeps it out of
// enableRecoveryProgressStateFlow for the passphrase path, so it is not retained
// anywhere once this local goes out of scope.
val storedKey = if (passphrase != null) "" else key
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(storedKey))
},
onFailure = {
observeEncryptionService(stateAndDispatch)
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()")
encryptionService.enableRecovery(waitForBackupsToUpload = false).onFailure {
Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery")
// The state machine only accepts Exception; wrap anything else so a failure
// still leaves the Creating spinner instead of hanging on it.
val exception = it as? Exception ?: RuntimeException(it)
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(exception))
if (it is Exception) {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
}
}
)
}
}
private fun CoroutineScope.observeEncryptionService(
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>
) = launch {
encryptionService.enableRecoveryProgressStateFlow.collect { enableRecoveryProgress ->
Timber.tag(loggerTagSetup.value).d("New enableRecoveryProgress: ${enableRecoveryProgress.javaClass.simpleName}")
when (enableRecoveryProgress) {
is EnableRecoveryProgress.Starting,
is EnableRecoveryProgress.CreatingBackup,
is EnableRecoveryProgress.CreatingRecoveryKey,
is EnableRecoveryProgress.BackingUp,
is EnableRecoveryProgress.RoomKeyUploadError -> Unit
is EnableRecoveryProgress.Done ->
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(enableRecoveryProgress.recoveryKey))
}
}
}
}
@@ -8,40 +8,16 @@
package io.element.android.features.securebackup.impl.setup
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
data class SecureBackupSetupState(
val isChangeRecoveryKeyUserStory: Boolean,
val recoveryKeyViewState: RecoveryKeyViewState,
val showSaveConfirmationDialog: Boolean,
val setupState: SetupState,
/** False while the well-known is still being fetched; the view shows a spinner until true. */
val wellknownLoaded: Boolean,
/** Non-null when the well-known requires a user-chosen passphrase; hides the generated-key UI. */
val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?,
val customEntryStep: CustomEntryStep,
/** User-typed passphrase. Not persisted to saved-state; cleared after a successful submit. */
val customPassphrase: String,
/** Confirmation field; same lifetime contract as [customPassphrase]. */
val customPassphraseConfirm: String,
/** True when [customPassphrase] satisfies the minimum-character-count rule from [customRecoveryPassphraseRequirements]. */
val customPassphraseMeetsMinLength: Boolean,
val customPassphraseMismatch: Boolean,
/** Null while the Entry-step passphrase is empty; otherwise the latest strength reading rendered below the field. */
val customPassphraseStrength: CustomRecoveryPassphraseStrengthResult?,
/** True when the Entry-step passphrase is valid enough to advance to the Confirm step. */
val canContinueFromEntry: Boolean,
val canSubmitCustomPassphrase: Boolean,
val eventSink: (SecureBackupSetupEvents) -> Unit
)
enum class CustomEntryStep {
Entry,
Confirm,
}
sealed interface SetupState {
data object Init : SetupState
data object Creating : SetupState
@@ -34,9 +34,6 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine<SecureBackupSetupSta
on { event: Event.SdkHasCreatedKey, state: MachineState<State.CreatingKey> ->
state.override { State.KeyCreated(event.key) }
}
on { _: Event.UserCancelledCreate, state: MachineState<State.CreatingKey> ->
state.override { State.Initial }
}
}
inState<State.KeyCreated> {
on { _: Event.UserSavedKey, state: MachineState<State.KeyCreated> ->
@@ -67,8 +64,5 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine<SecureBackupSetupSta
data class SdkError(val exception: Exception) : Event
data object UserSavedKey : Event
data object ClearError : Event
/** User backed out while the SDK call was still in flight; abort and return to Initial. */
data object UserCancelledCreate : Event
}
}
@@ -9,17 +9,13 @@
package io.element.android.features.securebackup.impl.setup
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrength
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBackupSetupState> {
override val values: Sequence<SecureBackupSetupState>
get() = sequenceOf(
aSecureBackupSetupState(wellknownLoaded = false),
aSecureBackupSetupState(setupState = SetupState.Init),
aSecureBackupSetupState(setupState = SetupState.Creating),
aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())),
@@ -29,141 +25,25 @@ open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBacku
showSaveConfirmationDialog = true,
),
aSecureBackupSetupState(setupState = SetupState.Error(Exception("Test error"))),
// Custom-entry: empty Entry step
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "",
),
// Custom-entry: Entry step too-short input
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "abc",
),
// Custom-entry: Entry step with a Garbage-tier passphrase (zero score, neutral colour)
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "abcdefgh",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Garbage, score = 0f),
),
// Custom-entry: Entry step with a Weak-tier passphrase
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "abcde1",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Weak, score = 0.15f),
),
// Custom-entry: Entry step with a Moderate-tier passphrase
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "Abcdefg1",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Moderate, score = 0.45f),
),
// Custom-entry: Entry step with a Strong-tier passphrase ready to continue
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "Abcdefg1!@#x",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.7f),
),
// Custom-entry: Entry step with a top-tier (Ideal) passphrase
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "Abcdefg1!@#xyz",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Mega, score = 1f),
),
// Custom-entry: Confirm step empty
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
customPassphrase = "abcdefgh",
customPassphraseConfirm = "",
),
// Custom-entry: Confirm step mismatch
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
customPassphrase = "abcdefgh",
customPassphraseConfirm = "different",
),
// Custom-entry: Confirm step ready to submit
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
customPassphrase = "abcdefgh",
customPassphraseConfirm = "abcdefgh",
),
// Custom-entry: submitting (spinner instead of base58 share UI)
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
setupState = SetupState.Creating,
),
// Custom-entry: SDK error overlay
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
setupState = SetupState.Error(Exception("Test error")),
),
// Add other states here
)
}
fun aSecureBackupSetupState(
isChangeRecoveryKeyUserStory: Boolean = false,
setupState: SetupState = SetupState.Init,
showSaveConfirmationDialog: Boolean = false,
wellknownLoaded: Boolean = true,
customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements? = null,
customEntryStep: CustomEntryStep = CustomEntryStep.Entry,
customPassphrase: String = "",
customPassphraseConfirm: String = "",
customPassphraseStrength: CustomRecoveryPassphraseStrengthResult? = null,
): SecureBackupSetupState {
val derivations = deriveCustomPassphraseState(
requirements = customRecoveryPassphraseRequirements,
passphrase = customPassphrase,
confirm = customPassphraseConfirm,
step = customEntryStep,
setupState = setupState,
)
return SecureBackupSetupState(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
setupState = setupState,
showSaveConfirmationDialog = showSaveConfirmationDialog,
recoveryKeyViewState = setupState.toRecoveryKeyViewState(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
suppressKey = customRecoveryPassphraseRequirements != null,
),
wellknownLoaded = wellknownLoaded,
customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements,
customEntryStep = customEntryStep,
customPassphrase = customPassphrase,
customPassphraseConfirm = customPassphraseConfirm,
customPassphraseMeetsMinLength = derivations.meetsMinLength,
customPassphraseMismatch = derivations.mismatch,
customPassphraseStrength = customPassphraseStrength,
canContinueFromEntry = derivations.canContinueFromEntry,
canSubmitCustomPassphrase = derivations.canSubmitCustomPassphrase,
eventSink = {}
)
}
private fun aPreviewCustomRecoveryPassphraseRequirements() = CustomRecoveryPassphraseRequirements(
minCharacterCount = 8,
) = SecureBackupSetupState(
isChangeRecoveryKeyUserStory = false,
setupState = setupState,
showSaveConfirmationDialog = showSaveConfirmationDialog,
recoveryKeyViewState = setupState.toRecoveryKeyViewState(),
eventSink = {}
)
private fun SetupState.toRecoveryKeyViewState(
isChangeRecoveryKeyUserStory: Boolean,
suppressKey: Boolean,
): RecoveryKeyViewState {
private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState {
return RecoveryKeyViewState(
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
// Match the presenter — the custom-passphrase flow never surfaces the SDK base58 key.
formattedRecoveryKey = if (suppressKey) null else recoveryKey(),
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = recoveryKey(),
displayTextFieldContents = true,
inProgress = this is SetupState.Creating,
)
@@ -8,42 +8,16 @@
package io.element.android.features.securebackup.impl.setup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrength
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
import io.element.android.libraries.androidutils.system.copyToClipboard
@@ -55,17 +29,8 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
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
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.PasswordVisibilityToggle
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -75,19 +40,13 @@ fun SecureBackupSetupView(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Custom flow auto-skips the "save your key" screen: finish once the SDK accepted it.
LaunchedEffect(state.setupState, state.isCustomEntry()) {
if (state.isCustomEntry() && state.setupState is SetupState.CreatedAndSaved) {
onSuccess()
}
}
FlowStepPage(
modifier = modifier,
onBackClick = backClickHandler(state, onBackClick),
onBackClick = onBackClick.takeIf { state.canGoBack() },
title = title(state),
subTitle = subtitle(state),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
buttons = { Buttons(state, onFinish = onSuccess, onCancel = onBackClick) },
buttons = { Buttons(state, onFinish = onSuccess) },
) {
Content(state = state)
}
@@ -119,45 +78,15 @@ private fun SecureBackupSetupState.canGoBack(): Boolean {
return recoveryKeyViewState.formattedRecoveryKey == null
}
private fun SecureBackupSetupState.isCustomEntry(): Boolean =
customRecoveryPassphraseRequirements != null
private fun backClickHandler(
state: SecureBackupSetupState,
onBackClick: () -> Unit,
): (() -> Unit)? {
if (!state.canGoBack()) return null
if (state.isCustomEntry()) {
// Back while the SDK call is in flight aborts it and returns to the Confirm step
// (typed input preserved) rather than silently flipping the step under the spinner.
if (state.setupState is SetupState.Creating) {
return { state.eventSink.invoke(SecureBackupSetupEvents.CancelCustomPassphraseSubmit) }
}
if (state.customEntryStep == CustomEntryStep.Confirm) {
// In the custom flow, backing out of Confirm returns to Entry (preserve typed input).
return { state.eventSink.invoke(SecureBackupSetupEvents.BackToCustomEntry) }
}
}
return onBackClick
}
@Composable
private fun title(state: SecureBackupSetupState): String {
// Hide the heading until the well-known resolves, so it can't flip copy under the user.
if (!state.wellknownLoaded) return ""
// Custom flow has no "save your key" step — use per-step custom titles throughout.
if (state.isCustomEntry()) {
return when (state.customEntryStep) {
CustomEntryStep.Entry -> stringResource(id = R.string.pro_screen_recovery_key_mode_input_title)
CustomEntryStep.Confirm -> stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_title)
}
}
return when (state.setupState) {
SetupState.Init,
SetupState.Creating,
is SetupState.Error -> when {
state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_title)
else -> stringResource(id = R.string.screen_recovery_key_setup_title)
is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) {
stringResource(id = R.string.screen_recovery_key_change_title)
} else {
stringResource(id = R.string.screen_recovery_key_setup_title)
}
is SetupState.Created,
is SetupState.CreatedAndSaved ->
@@ -166,20 +95,14 @@ private fun title(state: SecureBackupSetupState): String {
}
@Composable
private fun subtitle(state: SecureBackupSetupState): String? {
if (!state.wellknownLoaded) return null
if (state.isCustomEntry()) {
return when (state.customEntryStep) {
CustomEntryStep.Entry -> stringResource(id = R.string.pro_screen_recovery_key_mode_input_description)
CustomEntryStep.Confirm -> stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_description)
}
}
private fun subtitle(state: SecureBackupSetupState): String {
return when (state.setupState) {
SetupState.Init,
SetupState.Creating,
is SetupState.Error -> when {
state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_description)
else -> stringResource(id = R.string.screen_recovery_key_setup_description)
is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) {
stringResource(id = R.string.screen_recovery_key_change_description)
} else {
stringResource(id = R.string.screen_recovery_key_setup_description)
}
is SetupState.Created,
is SetupState.CreatedAndSaved ->
@@ -191,25 +114,6 @@ private fun subtitle(state: SecureBackupSetupState): String? {
private fun Content(
state: SecureBackupSetupState,
) {
// Spinner until the well-known resolves, so neither flow renders before we know which applies.
if (!state.wellknownLoaded && state.setupState == SetupState.Init) {
LoadingPlaceholder()
return
}
if (state.isCustomEntry()) {
when (state.setupState) {
SetupState.Init,
is SetupState.Error -> when (state.customEntryStep) {
CustomEntryStep.Entry -> CustomPassphraseEntry(state = state)
CustomEntryStep.Confirm -> CustomPassphraseConfirm(state = state)
}
// Hold the spinner through the auto-skip so the SDK base58 key is never shown/shared.
SetupState.Creating,
is SetupState.Created,
is SetupState.CreatedAndSaved -> LoadingPlaceholder()
}
return
}
val context = LocalContext.current
val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey
val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard)
@@ -240,268 +144,10 @@ private fun Content(
)
}
@Composable
private fun LoadingPlaceholder() {
val description = stringResource(id = R.string.a11y_recovery_key_loading_specs)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 52.dp)
.semantics { contentDescription = description },
contentAlignment = Alignment.Center,
) {
// CircularProgressIndicator applies progressSemantics() internally — no need to add it here.
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
}
@Composable
private fun CustomPassphraseEntry(state: SecureBackupSetupState) {
val specs = state.customRecoveryPassphraseRequirements ?: return
var passphraseVisible by rememberSaveable { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
TextField(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.customRecoveryPassphrase)
.semantics { contentType = ContentType.NewPassword },
value = state.customPassphrase,
onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(it)) },
label = stringResource(id = CommonStrings.common_recovery_key),
singleLine = true,
visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
PasswordVisibilityToggle(
visible = passphraseVisible,
onToggle = { passphraseVisible = !passphraseVisible },
)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
if (state.canContinueFromEntry) {
state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
}
},
),
supportingText = stringResource(
id = R.string.pro_screen_recovery_key_mode_input_passphrase_field_footer,
specs.minCharacterCount,
),
)
// Always render the strength row in the Entry step (matches iOS): an empty field shows the
// "Strength" base label with an empty bar; typing flips it to the per-level label + bar.
Spacer(modifier = Modifier.height(8.dp))
PassphraseStrengthIndicator(
result = state.customPassphraseStrength,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun PassphraseStrengthIndicator(
result: CustomRecoveryPassphraseStrengthResult?,
modifier: Modifier = Modifier,
) {
val label = stringResource(
id = result?.strength?.labelRes() ?: R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_base,
)
val hint = result?.strength?.hintRes()?.let { stringResource(id = it) }
val score = result?.score ?: 0f
// Mirror iOS: a zero score (empty field or the Garbage tier) stays neutral; otherwise the
// label and bar share a single colour interpolated along the red→orange→yellow→green gradient.
val color = if (score <= 0f) ElementTheme.colors.textSecondary else passphraseStrengthColor(score)
val announcement = stringResource(id = R.string.a11y_recovery_key_custom_strength_announcement, label)
.let { if (hint != null) "$it. $hint" else it }
Column(
modifier = modifier.semantics(mergeDescendants = true) {
contentDescription = announcement
},
) {
Text(
text = label,
color = color,
style = ElementTheme.typography.fontBodySmMedium,
)
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { score },
modifier = Modifier.fillMaxWidth(),
color = color,
)
if (hint != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = hint,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
}
private fun CustomRecoveryPassphraseStrength.labelRes(): Int = when (this) {
CustomRecoveryPassphraseStrength.Garbage -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_garbage
CustomRecoveryPassphraseStrength.Weak -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_weak
CustomRecoveryPassphraseStrength.Moderate -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_moderate
CustomRecoveryPassphraseStrength.Okay -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_okay
CustomRecoveryPassphraseStrength.Strong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_strong
CustomRecoveryPassphraseStrength.VeryStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_very_strong
CustomRecoveryPassphraseStrength.UltraStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_ultra_strong
CustomRecoveryPassphraseStrength.Mega -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_mega
}
private fun CustomRecoveryPassphraseStrength.hintRes(): Int = when (this) {
CustomRecoveryPassphraseStrength.Garbage -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_garbage
CustomRecoveryPassphraseStrength.Weak -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_weak
CustomRecoveryPassphraseStrength.Moderate -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_moderate
CustomRecoveryPassphraseStrength.Okay -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_okay
CustomRecoveryPassphraseStrength.Strong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_strong
CustomRecoveryPassphraseStrength.VeryStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_very_strong
CustomRecoveryPassphraseStrength.UltraStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_ultra_strong
CustomRecoveryPassphraseStrength.Mega -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_mega
}
// Stops mirror iOS PasswordStrengthBar.gradient: Compound red900 / lime700 with the same vivid
// orange & yellow midpoints. Intentionally fixed (not light/dark semantic) so the gradient reads
// identically to iOS in both themes.
private val passphraseStrengthGradientStops = listOf(
0.25f to Color(0xFFD51928), // Compound red900
0.5f to Color(0xFFFF9500), // orange
0.75f to Color(0xFFFFCC00), // yellow
1.0f to Color(0xFF54C424), // Compound lime700
)
/** Interpolates the strength bar colour along [passphraseStrengthGradientStops] for a 0f..1f score. */
private fun passphraseStrengthColor(score: Float): Color {
val clamped = score.coerceIn(0f, 1f)
var previousLocation = 0f
var previousColor = passphraseStrengthGradientStops.first().second
for ((location, color) in passphraseStrengthGradientStops) {
if (clamped <= location) {
val span = location - previousLocation
if (span <= 0f) return color
return lerp(previousColor, color, ((clamped - previousLocation) / span).coerceIn(0f, 1f))
}
previousLocation = location
previousColor = color
}
return previousColor
}
@Composable
private fun CustomPassphraseConfirm(state: SecureBackupSetupState) {
var passphraseVisible by rememberSaveable { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
TextField(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.customRecoveryPassphraseConfirm)
.semantics { contentType = ContentType.NewPassword },
value = state.customPassphraseConfirm,
onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(it)) },
label = stringResource(id = CommonStrings.common_recovery_key),
singleLine = true,
visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
PasswordVisibilityToggle(
visible = passphraseVisible,
onToggle = { passphraseVisible = !passphraseVisible },
)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
if (state.canSubmitCustomPassphrase) {
state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
}
},
),
validity = if (state.customPassphraseMismatch) TextFieldValidity.Invalid else TextFieldValidity.None,
supportingText = if (state.customPassphraseMismatch) {
stringResource(id = R.string.pro_screen_recovery_key_mode_custom_mismatch)
} else {
null
},
)
}
}
@Composable
private fun ColumnScope.Buttons(
state: SecureBackupSetupState,
onFinish: () -> Unit,
onCancel: () -> Unit,
) {
// No buttons until the well-known resolves; Content shows a spinner meanwhile.
if (!state.wellknownLoaded && state.setupState == SetupState.Init) return
if (state.isCustomEntry()) {
CustomButtons(state = state, onCancel = onCancel)
} else {
AutoGenButtons(state = state, onFinish = onFinish)
}
}
@Composable
private fun ColumnScope.CustomButtons(
state: SecureBackupSetupState,
onCancel: () -> Unit,
) {
// No save/share controls in the custom flow — they'd leak the base58 key during Created.
when (state.setupState) {
SetupState.Creating,
is SetupState.Created,
is SetupState.CreatedAndSaved -> return
SetupState.Init,
is SetupState.Error -> Unit
}
when (state.customEntryStep) {
CustomEntryStep.Entry -> {
Button(
text = stringResource(id = CommonStrings.action_continue),
enabled = state.canContinueFromEntry,
modifier = Modifier.fillMaxWidth(),
onClick = { state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) },
)
TextButton(
text = stringResource(id = CommonStrings.action_cancel),
modifier = Modifier.fillMaxWidth(),
onClick = onCancel,
)
}
CustomEntryStep.Confirm -> {
Button(
text = stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_finish_button),
enabled = state.canSubmitCustomPassphrase,
modifier = Modifier.fillMaxWidth(),
onClick = { state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) },
)
}
}
}
@Composable
private fun ColumnScope.AutoGenButtons(
state: SecureBackupSetupState,
onFinish: () -> Unit,
) {
val context = LocalContext.current
val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action)
@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="pro_screen_recovery_key_mode_input_title">Enter a recovery key</string>
<string name="pro_screen_recovery_key_mode_input_description">Choose a recovery key that you can memorize.</string>
<string name="pro_screen_recovery_key_mode_confirm_title">Confirm your recovery key</string>
<string name="pro_screen_recovery_key_mode_confirm_description">Enter your recovery key again.</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_field_footer">Minimum %1$s characters</string>
<string name="pro_screen_recovery_key_mode_confirm_finish_button">Finish setup</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_base">Strength</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_garbage">Garbage</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_weak">Weak</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_moderate">Moderate</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_okay">Okay</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_strong">Strong</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_very_strong">Very Strong</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_ultra_strong">Super Strong</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_mega">Ideal</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_garbage">Cracking would take roughly milliseconds.</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_weak">Cracking would take roughly minutes to months.</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_moderate">Cracking would take roughly years.</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_okay">Cracking would take roughly hundreds of years.</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_strong">Cracking would take roughly billions of years.</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_very_strong">Cracking would take roughly trillions of years.</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_ultra_strong">Cracking would take roughly quadrillions of years.</string>
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_mega">Cracking would take roughly quintillions of years.</string>
<!-- Android-only strings: no iOS counterpart was provided, so keys are left as-is. -->
<string name="pro_screen_recovery_key_mode_custom_mismatch">The recovery key you entered doesn\'t match</string>
<string name="a11y_recovery_key_loading_specs">Loading recovery key requirements</string>
<string name="a11y_recovery_key_custom_strength_announcement">Passphrase strength: %1$s</string>
</resources>
@@ -6,37 +6,23 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
package io.element.android.features.securebackup.impl.setup
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.features.enterprise.api.CustomRecoveryPassphraseStrength
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.features.wellknown.test.FakeSessionWellknownRetriever
import io.element.android.features.wellknown.test.aCustomRecoveryPassphraseRequirements
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.A_RECOVERY_KEY
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@Suppress("LargeClass")
class SecureBackupSetupPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -47,16 +33,11 @@ class SecureBackupSetupPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val preFetch = awaitItem()
assertThat(preFetch.wellknownLoaded).isFalse()
assertThat(preFetch.setupState).isEqualTo(SetupState.Init)
val loaded = awaitItem()
assertThat(loaded.wellknownLoaded).isTrue()
assertThat(loaded.isChangeRecoveryKeyUserStory).isFalse()
assertThat(loaded.setupState).isEqualTo(SetupState.Init)
assertThat(loaded.showSaveConfirmationDialog).isFalse()
assertThat(loaded.recoveryKeyViewState).isEqualTo(
val initialState = awaitItem()
assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse()
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
assertThat(initialState.showSaveConfirmationDialog).isFalse()
assertThat(initialState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = null,
@@ -69,17 +50,14 @@ class SecureBackupSetupPresenterTest {
@Test
fun `present - create recovery key and save it`() = runTest {
val encryptionService = FakeEncryptionService(
enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) },
)
val encryptionService = FakeEncryptionService()
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val initialState = awaitItem() // post-fetch, wellknownLoaded=true
val initialState = awaitItem()
initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
val creatingState = awaitItem()
assertThat(creatingState.setupState).isEqualTo(SetupState.Creating)
@@ -91,6 +69,7 @@ class SecureBackupSetupPresenterTest {
inProgress = true,
)
)
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
val createdState = awaitItem()
assertThat(createdState.setupState).isEqualTo(SetupState.Created(A_RECOVERY_KEY))
assertThat(createdState.recoveryKeyViewState).isEqualTo(
@@ -121,9 +100,7 @@ class SecureBackupSetupPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val initialState = awaitItem()
assertThat(initialState.wellknownLoaded).isTrue()
assertThat(initialState.isChangeRecoveryKeyUserStory).isTrue()
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
assertThat(initialState.recoveryKeyViewState).isEqualTo(
@@ -149,7 +126,6 @@ class SecureBackupSetupPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val initialState = awaitItem()
assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse()
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
@@ -176,7 +152,6 @@ class SecureBackupSetupPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val initialState = awaitItem()
initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
val creatingState = awaitItem()
@@ -211,727 +186,16 @@ class SecureBackupSetupPresenterTest {
}
}
@Test
fun `present - wellknownLoaded flips false to true exactly once after fetch resolves`() = runTest {
val presenter = createSecureBackupSetupPresenter(
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val preFetch = awaitItem()
assertThat(preFetch.wellknownLoaded).isFalse()
assertThat(preFetch.customRecoveryPassphraseRequirements).isNull()
val loaded = awaitItem()
assertThat(loaded.wellknownLoaded).isTrue()
assertThat(loaded.customRecoveryPassphraseRequirements).isNotNull()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec absent leaves customRecoveryPassphraseRequirements null and uses generated-key path`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = null))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val preFetch = awaitItem()
assertThat(preFetch.wellknownLoaded).isFalse()
val loaded = awaitItem()
assertThat(loaded.wellknownLoaded).isTrue()
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
assertThat(loaded.canSubmitCustomPassphrase).isFalse()
loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
awaitItem() // creating
advanceUntilIdle()
enableRecoveryLambda.assertions().isCalledOnce()
.with(value(false), value(null))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec present surfaces validation errors and blocks continue`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // initial, pre-fetch
val withSpec = awaitItem()
assertThat(withSpec.customRecoveryPassphraseRequirements).isNotNull()
assertThat(withSpec.customEntryStep).isEqualTo(CustomEntryStep.Entry)
assertThat(withSpec.canContinueFromEntry).isFalse()
assertThat(withSpec.canSubmitCustomPassphrase).isFalse()
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc"))
val afterShortInput = awaitItem()
assertThat(afterShortInput.customPassphrase).isEqualTo("abc")
assertThat(afterShortInput.customPassphraseMeetsMinLength).isFalse()
assertThat(afterShortInput.canContinueFromEntry).isFalse()
// Continue is a no-op when entry is invalid: step stays Entry.
afterShortInput.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
expectNoEvents()
enableRecoveryLambda.assertions().isNeverCalled()
}
}
@Test
fun `present - custom spec strength is null while empty and delegates to the enterprise estimator once user types`() = runTest {
// The estimation algorithm lives in the enterprise module; here we only verify the presenter
// suppresses the indicator while the field is empty and otherwise forwards the service result.
val presenter = createSecureBackupSetupPresenter(
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
enterpriseService = FakeEnterpriseService(
isCustomRecoveryPassphraseEnabledResult = true,
estimateCustomRecoveryPassphraseStrengthResult = { passphrase ->
if (passphrase.length > 5) {
CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.9f)
} else {
CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Weak, score = 0.1f)
}
},
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val withSpec = awaitItem()
// Field is empty → indicator suppressed (the estimator is never consulted).
assertThat(withSpec.customPassphraseStrength).isNull()
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc"))
val afterShort = awaitItem()
assertThat(afterShort.customPassphraseStrength?.strength)
.isEqualTo(CustomRecoveryPassphraseStrength.Weak)
afterShort.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("Abcdefg1!@#xyz"))
val afterStrong = awaitItem()
assertThat(afterStrong.customPassphraseStrength?.strength)
.isEqualTo(CustomRecoveryPassphraseStrength.Strong)
// Clearing the field brings the indicator back to null.
afterStrong.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(""))
val cleared = awaitItem()
assertThat(cleared.customPassphraseStrength).isNull()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec ContinueCustomPassphrase advances Entry to Confirm when valid`() = runTest {
val presenter = createSecureBackupSetupPresenter(
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // initial
val withSpec = awaitItem()
assertThat(withSpec.customEntryStep).isEqualTo(CustomEntryStep.Entry)
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
val typed = awaitItem()
assertThat(typed.canContinueFromEntry).isTrue()
typed.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val advanced = awaitItem()
assertThat(advanced.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
assertThat(advanced.customPassphrase).isEqualTo(valid)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec BackToCustomEntry returns to Entry preserving both fields`() = runTest {
val presenter = createSecureBackupSetupPresenter(
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // initial
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
awaitItem()
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
awaitItem()
onConfirm.eventSink.invoke(SecureBackupSetupEvents.BackToCustomEntry)
val backOnEntry = awaitItem()
assertThat(backOnEntry.customEntryStep).isEqualTo(CustomEntryStep.Entry)
assertThat(backOnEntry.customPassphrase).isEqualTo(valid)
assertThat(backOnEntry.customPassphraseConfirm).isEqualTo(valid)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec SubmitCustomPassphrase from Entry step is a no-op`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // initial
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
val typed = awaitItem()
// Both fields contain matching content via a stale path: simulate by also setting confirm
// while still on the Entry step (e.g., previously typed). Submit must still be gated.
typed.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val bothTyped = awaitItem()
assertThat(bothTyped.customEntryStep).isEqualTo(CustomEntryStep.Entry)
assertThat(bothTyped.canSubmitCustomPassphrase).isFalse()
bothTyped.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
expectNoEvents()
enableRecoveryLambda.assertions().isNeverCalled()
}
}
@Test
fun `present - custom spec present submit with valid input forwards passphrase to SDK`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // initial
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
val afterKey = awaitItem()
afterKey.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val ready = awaitItem()
assertThat(ready.canSubmitCustomPassphrase).isTrue()
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
awaitItem() // creating
advanceUntilIdle()
enableRecoveryLambda.assertions().isCalledOnce()
.with(value(false), value(valid))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec error retains typed passphrase and Confirm step across DismissDialog`() = runTest {
val encryptionService = FakeEncryptionService(
enableRecoveryLambda = { _, _ -> Result.failure(IllegalStateException("boom")) },
)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
awaitItem()
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val ready = awaitItem()
assertThat(ready.canSubmitCustomPassphrase).isTrue()
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
awaitItem() // creating
val errored = awaitItem()
assertThat(errored.setupState).isInstanceOf(SetupState.Error::class.java)
errored.eventSink.invoke(SecureBackupSetupEvents.DismissDialog)
val afterDismiss = awaitItem()
assertThat(afterDismiss.setupState).isEqualTo(SetupState.Init)
assertThat(afterDismiss.customPassphrase).isEqualTo(valid)
assertThat(afterDismiss.customPassphraseConfirm).isEqualTo(valid)
assertThat(afterDismiss.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
assertThat(afterDismiss.canSubmitCustomPassphrase).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec submit success auto-advances to CreatedAndSaved without manual save`() = runTest {
val encryptionService = FakeEncryptionService(
enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) },
)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
awaitItem()
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val ready = awaitItem()
assertThat(ready.canSubmitCustomPassphrase).isTrue()
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
awaitItem() // creating
advanceUntilIdle()
// The presenter's auto-skip LaunchedEffect should have advanced state machine past
// Created without requiring a manual RecoveryKeyHasBeenSaved event. The terminal
// state observed is CreatedAndSaved.
val finalState = expectMostRecentItem()
assertThat(finalState.setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
// The SDK-derived base58 key must not leak through the recoveryKeyViewState in
// the custom-passphrase flow.
assertThat(finalState.recoveryKeyViewState.formattedRecoveryKey).isNull()
// The state machine's KeyCreatedAndSaved variant carries a key too — confirm
// the presenter scrubbed it before dispatching SdkHasCreatedKey, so observers
// of setupState don't see the SDK-generated base58 key either.
assertThat(finalState.setupState).isEqualTo(SetupState.CreatedAndSaved(formattedRecoveryKey = ""))
assertThat(finalState.setupState.recoveryKey()).isEqualTo("")
// Typed passphrase is cleared once the SDK call returns successfully.
assertThat(finalState.customPassphrase).isEmpty()
assertThat(finalState.customPassphraseConfirm).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - enterprise feature disabled suppresses spec and forces auto-gen path`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
enterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = false),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val loaded = awaitItem()
assertThat(loaded.wellknownLoaded).isTrue()
// Even though the homeserver advertised a spec, the enterprise gate suppresses it.
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
assertThat(loaded.canSubmitCustomPassphrase).isFalse()
loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
awaitItem() // creating
advanceUntilIdle()
enableRecoveryLambda.assertions().isCalledOnce()
.with(value(false), value(null))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - well-known fetch failure falls back to generated-key path`() = runTest {
val presenter = createSecureBackupSetupPresenter(
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Error(IllegalStateException("boom"))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val preFetch = awaitItem()
assertThat(preFetch.wellknownLoaded).isFalse()
val loaded = awaitItem()
assertThat(loaded.wellknownLoaded).isTrue()
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - change + custom spec absent uses resetRecoveryKey path`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = true,
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = null))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val loaded = awaitItem()
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
awaitItem() // creating
val createdState = awaitItem()
assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY))
enableRecoveryLambda.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - change + custom spec present surfaces validation errors and blocks continue`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = true,
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // initial, pre-fetch
val withSpec = awaitItem()
assertThat(withSpec.customRecoveryPassphraseRequirements).isNotNull()
assertThat(withSpec.canContinueFromEntry).isFalse()
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc"))
val afterShortInput = awaitItem()
assertThat(afterShortInput.customPassphrase).isEqualTo("abc")
assertThat(afterShortInput.customPassphraseMeetsMinLength).isFalse()
assertThat(afterShortInput.canContinueFromEntry).isFalse()
afterShortInput.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
expectNoEvents()
enableRecoveryLambda.assertions().isNeverCalled()
}
}
@Test
fun `present - change + custom spec present submit with valid input forwards passphrase to enableRecovery`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = true,
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // initial
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
val afterKey = awaitItem()
afterKey.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val ready = awaitItem()
assertThat(ready.canSubmitCustomPassphrase).isTrue()
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
awaitItem() // creating
advanceUntilIdle()
enableRecoveryLambda.assertions().isCalledOnce()
.with(value(false), value(valid))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - change + well-known fetch failure falls back to resetRecoveryKey path`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = true,
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Error(IllegalStateException("boom"))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val loaded = awaitItem()
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
awaitItem() // creating
val createdState = awaitItem()
assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY))
enableRecoveryLambda.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec Confirm mismatch clears once user edits to match`() = runTest {
val presenter = createSecureBackupSetupPresenter(
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
awaitItem()
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
assertThat(onConfirm.customPassphraseMismatch).isFalse()
// Typing a different value flips mismatch=true and blocks submit.
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm("differen"))
val mismatched = awaitItem()
assertThat(mismatched.customPassphraseMismatch).isTrue()
assertThat(mismatched.canSubmitCustomPassphrase).isFalse()
// Editing the confirm field to match clears the mismatch and re-enables submit.
mismatched.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val matched = awaitItem()
assertThat(matched.customPassphraseMismatch).isFalse()
assertThat(matched.canSubmitCustomPassphrase).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec rapid double-submit invokes the SDK only once`() = runTest {
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
awaitItem()
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val ready = awaitItem()
assertThat(ready.canSubmitCustomPassphrase).isTrue()
// Two synchronous submits dispatched from the same snapshot (e.g., button + IME-Done
// racing). The presenter's in-flight guard must drop the second one before it
// launches a second enableRecovery coroutine.
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
advanceUntilIdle()
enableRecoveryLambda.assertions().isCalledOnce()
.with(value(false), value(valid))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - change + custom spec submit routes to enableRecovery and never resetRecoveryKey`() = runTest {
// Guards the custom-passphrase Change path against silently dropping the passphrase:
// the user's passphrase reaches the SDK only via enableRecovery(passphrase); the
// passphrase-less resetRecoveryKey() path must never be taken here. A true end-to-end
// check that the 4S key actually rotates needs the real SDK + homeserver and lives
// outside this JVM suite.
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val resetRecoveryKeyLambda = lambdaRecorder<Result<String>> { Result.success(FakeEncryptionService.FAKE_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(
enableRecoveryLambda = enableRecoveryLambda,
resetRecoveryKeyLambda = resetRecoveryKeyLambda,
)
val presenter = createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = true,
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
awaitItem()
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val ready = awaitItem()
assertThat(ready.canSubmitCustomPassphrase).isTrue()
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
awaitItem() // creating
advanceUntilIdle()
enableRecoveryLambda.assertions().isCalledOnce()
.with(value(false), value(valid))
resetRecoveryKeyLambda.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - custom spec cancel while creating aborts the SDK call and returns to Confirm with input preserved`() = runTest {
// simulateLongTask suspends on delay(1) before invoking the lambda, so cancelling before
// advancing virtual time guarantees the SDK call never runs.
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
awaitItem()
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
val ready = awaitItem()
assertThat(ready.canSubmitCustomPassphrase).isTrue()
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
val creating = awaitItem()
assertThat(creating.setupState).isEqualTo(SetupState.Creating)
creating.eventSink.invoke(SecureBackupSetupEvents.CancelCustomPassphraseSubmit)
val cancelled = awaitItem()
assertThat(cancelled.setupState).isEqualTo(SetupState.Init)
assertThat(cancelled.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
assertThat(cancelled.customPassphrase).isEqualTo(valid)
assertThat(cancelled.customPassphraseConfirm).isEqualTo(valid)
assertThat(cancelled.canSubmitCustomPassphrase).isTrue()
// The cancelled coroutine never reached the SDK, and no zombie success/error followed.
advanceUntilIdle()
enableRecoveryLambda.assertions().isNeverCalled()
expectNoEvents()
}
}
@Test
fun `present - custom spec strength indicator is suppressed on the Confirm step`() = runTest {
val presenter = createSecureBackupSetupPresenter(
sessionWellknownRetriever = FakeSessionWellknownRetriever {
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
},
enterpriseService = FakeEnterpriseService(
isCustomRecoveryPassphraseEnabledResult = true,
estimateCustomRecoveryPassphraseStrengthResult = {
CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.9f)
},
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem() // pre-fetch
val withSpec = awaitItem()
val valid = "Ab12!@cd"
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
val onEntry = awaitItem()
// Entry step: the estimator result is surfaced.
assertThat(onEntry.customPassphraseStrength?.strength).isEqualTo(CustomRecoveryPassphraseStrength.Strong)
onEntry.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
val onConfirm = awaitItem()
// Confirm step: indicator is not rendered, so the presenter stops computing it.
assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
assertThat(onConfirm.customPassphraseStrength).isNull()
cancelAndIgnoreRemainingEvents()
}
}
private fun createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory: Boolean = false,
encryptionService: EncryptionService = FakeEncryptionService(
enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) },
enableRecoveryLambda = { _, _ -> Result.success("") },
),
sessionWellknownRetriever: SessionWellknownRetriever = FakeSessionWellknownRetriever(),
enterpriseService: EnterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = true),
): SecureBackupSetupPresenter {
return SecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
stateMachine = SecureBackupSetupStateMachine(),
encryptionService = encryptionService,
sessionWellknownRetriever = sessionWellknownRetriever,
enterpriseService = enterpriseService,
)
}
}