From db6f4414df4de42c91415f8b91c8493637648a92 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 2 Jun 2026 13:52:21 -0700 Subject: [PATCH] Add custom recovery passphrase setup flow When the homeserver advertises custom_recovery_passphrase_settings (and the enterprise gate is enabled), the secure-backup setup lets the user enter and confirm their own recovery key with a live strength indicator, instead of receiving a generated one. The SDK derives the 4S key from the passphrase and the base58 key is scrubbed everywhere so it is never shown. Falls back to the generated-key flow when no spec is present or the well-known fetch fails. Snapshot PNGs are intentionally left out; the core team regenerates them after the PR is opened. Co-Authored-By: Claude Opus 4.8 (1M context) --- features/securebackup/impl/build.gradle.kts | 3 + .../impl/setup/CustomPassphraseDerivations.kt | 51 ++ .../impl/setup/SecureBackupSetupEvents.kt | 18 + .../impl/setup/SecureBackupSetupPresenter.kt | 206 ++++- .../impl/setup/SecureBackupSetupState.kt | 24 + .../setup/SecureBackupSetupStateMachine.kt | 6 + .../setup/SecureBackupSetupStateProvider.kt | 140 +++- .../impl/setup/SecureBackupSetupView.kt | 378 ++++++++- .../impl/src/main/res/values/temporary.xml | 37 + .../setup/SecureBackupSetupPresenterTest.kt | 758 +++++++++++++++++- .../android/libraries/testtags/TestTags.kt | 2 + 11 files changed, 1552 insertions(+), 71 deletions(-) create mode 100644 features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt create mode 100644 features/securebackup/impl/src/main/res/values/temporary.xml diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts index 54d87ef22e..d7dafc3e65 100644 --- a/features/securebackup/impl/build.gradle.kts +++ b/features/securebackup/impl/build.gradle.kts @@ -38,9 +38,12 @@ 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) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt new file mode 100644 index 0000000000..7bf6e5805c --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt @@ -0,0 +1,51 @@ +/* + * 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, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt index f61e65ba61..faf3d618fe 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt @@ -13,4 +13,22 @@ 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 } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt index 9f27bc9566..9145b4a49b 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt @@ -11,6 +11,7 @@ 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 @@ -22,22 +23,33 @@ 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 { @AssistedFactory interface Factory { @@ -53,10 +65,81 @@ 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(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) + coroutineScope.createOrChangeRecoveryKey(stateAndDispatch, passphrase = null) } SecureBackupSetupEvents.RecoveryKeyHasBeenSaved -> stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) @@ -67,12 +150,51 @@ 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, - formattedRecoveryKey = setupState.recoveryKey(), + // 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(), displayTextFieldContents = true, inProgress = setupState is SetupState.Creating, ) @@ -82,6 +204,16 @@ 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, ) } @@ -98,47 +230,43 @@ class SecureBackupSetupPresenter( } private fun CoroutineScope.createOrChangeRecoveryKey( - stateAndDispatch: StateAndDispatch + stateAndDispatch: StateAndDispatch, + passphrase: String?, + onSuccess: () -> Unit = {}, ) = launch { stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey) - if (isChangeRecoveryKeyUserStory) { + // 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) { Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()") - encryptionService.resetRecoveryKey().fold( - onSuccess = { - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it)) - }, - onFailure = { - if (it is Exception) { - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it)) - } - } - ) + encryptionService.resetRecoveryKey() } else { - observeEncryptionService(stateAndDispatch) - Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()") - encryptionService.enableRecovery(waitForBackupsToUpload = false).onFailure { + 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 = { Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery") - if (it is Exception) { - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it)) - } + // 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)) } - } - } - - private fun CoroutineScope.observeEncryptionService( - stateAndDispatch: StateAndDispatch - ) = 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)) - } - } + ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt index 752b5e4851..0e5b60dab7 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt @@ -8,16 +8,40 @@ 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 diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt index 150aeeddc7..62478e3b87 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt @@ -34,6 +34,9 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine -> state.override { State.KeyCreated(event.key) } } + on { _: Event.UserCancelledCreate, state: MachineState -> + state.override { State.Initial } + } } inState { on { _: Event.UserSavedKey, state: MachineState -> @@ -64,5 +67,8 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine { override val values: Sequence get() = sequenceOf( + aSecureBackupSetupState(wellknownLoaded = false), aSecureBackupSetupState(setupState = SetupState.Init), aSecureBackupSetupState(setupState = SetupState.Creating), aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())), @@ -25,25 +29,141 @@ open class SecureBackupSetupStateProvider : PreviewParameterProvider 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 = onBackClick.takeIf { state.canGoBack() }, + onBackClick = backClickHandler(state, onBackClick), title = title(state), subTitle = subtitle(state), iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), - buttons = { Buttons(state, onFinish = onSuccess) }, + buttons = { Buttons(state, onFinish = onSuccess, onCancel = onBackClick) }, ) { Content(state = state) } @@ -78,15 +120,45 @@ 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.screen_recovery_key_custom_title) + CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_title) + } + } return when (state.setupState) { SetupState.Init, SetupState.Creating, - 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.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.Created, is SetupState.CreatedAndSaved -> @@ -95,14 +167,20 @@ private fun title(state: SecureBackupSetupState): String { } @Composable -private fun subtitle(state: SecureBackupSetupState): String { +private fun subtitle(state: SecureBackupSetupState): String? { + if (!state.wellknownLoaded) return null + if (state.isCustomEntry()) { + return when (state.customEntryStep) { + CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_description) + CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_description) + } + } return when (state.setupState) { SetupState.Init, SetupState.Creating, - 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.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.Created, is SetupState.CreatedAndSaved -> @@ -114,6 +192,25 @@ 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) @@ -144,10 +241,269 @@ 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 = pluralStringResource( + id = R.plurals.screen_recovery_key_custom_requirement_length, + count = specs.minCharacterCount, + 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.screen_recovery_key_custom_strength_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.screen_recovery_key_custom_strength_garbage + CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_weak + CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_moderate + CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_okay + CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_strong + CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_very_strong + CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_ultra_strong + CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_mega +} + +private fun CustomRecoveryPassphraseStrength.hintRes(): Int = when (this) { + CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_hint_garbage + CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_hint_weak + CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_hint_moderate + CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_hint_okay + CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_hint_strong + CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_hint_very_strong + CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_hint_ultra_strong + CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_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.screen_recovery_key_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.screen_recovery_key_custom_finish_action), + 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) diff --git a/features/securebackup/impl/src/main/res/values/temporary.xml b/features/securebackup/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..3edbd84d73 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values/temporary.xml @@ -0,0 +1,37 @@ + + + Enter a custom recovery key + Choose a recovery key that you can memorize. + Confirm your recovery key + Enter your recovery key again. + The recovery key you entered doesn\'t match + + Minimum %1$d character + Minimum %1$d characters + + Finish setup + Strength + Garbage + Weak + Moderate + Okay + Strong + Very strong + Super strong + Ideal + Cracking would take roughly milliseconds. + Cracking would take roughly minutes to months. + Cracking would take roughly years. + Cracking would take roughly hundreds of years. + Cracking would take roughly billions of years. + Cracking would take roughly trillions of years. + Cracking would take roughly quadrillions of years. + Cracking would take roughly quintillions of years. + Loading recovery key requirements + Passphrase strength: %1$s + diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt index a3cf920d82..6ca716bcd6 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt @@ -6,23 +6,37 @@ * 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.libraries.matrix.api.encryption.EnableRecoveryProgress +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.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() @@ -33,11 +47,16 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse() - assertThat(initialState.setupState).isEqualTo(SetupState.Init) - assertThat(initialState.showSaveConfirmationDialog).isFalse() - assertThat(initialState.recoveryKeyViewState).isEqualTo( + 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( RecoveryKeyViewState( recoveryKeyUserStory = RecoveryKeyUserStory.Setup, formattedRecoveryKey = null, @@ -50,14 +69,17 @@ class SecureBackupSetupPresenterTest { @Test fun `present - create recovery key and save it`() = runTest { - val encryptionService = FakeEncryptionService() + val encryptionService = FakeEncryptionService( + enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) }, + ) val presenter = createSecureBackupSetupPresenter( encryptionService = encryptionService ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() + awaitItem() // pre-fetch + val initialState = awaitItem() // post-fetch, wellknownLoaded=true initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) val creatingState = awaitItem() assertThat(creatingState.setupState).isEqualTo(SetupState.Creating) @@ -69,7 +91,6 @@ 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( @@ -100,7 +121,9 @@ 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( @@ -117,7 +140,7 @@ class SecureBackupSetupPresenterTest { @Test fun `present - handle errors`() = runTest { val encryptionService = FakeEncryptionService( - enableRecoveryLambda = { Result.failure(IllegalStateException("Test error")) } + enableRecoveryLambda = { _, _ -> Result.failure(IllegalStateException("Test error")) } ) val presenter = createSecureBackupSetupPresenter( isChangeRecoveryKeyUserStory = false, @@ -126,6 +149,7 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + awaitItem() // pre-fetch val initialState = awaitItem() assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse() assertThat(initialState.setupState).isEqualTo(SetupState.Init) @@ -152,6 +176,7 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + awaitItem() // pre-fetch val initialState = awaitItem() initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) val creatingState = awaitItem() @@ -186,16 +211,727 @@ 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> 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> { _, _ -> Result.success(A_RECOVERY_KEY) } + val resetRecoveryKeyLambda = lambdaRecorder> { 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> { _, _ -> 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(Unit) }, + enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) }, ), + sessionWellknownRetriever: SessionWellknownRetriever = FakeSessionWellknownRetriever(), + enterpriseService: EnterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = true), ): SecureBackupSetupPresenter { return SecureBackupSetupPresenter( isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = encryptionService, + sessionWellknownRetriever = sessionWellknownRetriever, + enterpriseService = enterpriseService, ) } } diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 4b3a0d6ffa..593560936e 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -29,6 +29,8 @@ object TestTags { * Verification screen. */ val recoveryKey = TestTag("verification-recovery_key") + val customRecoveryPassphrase = TestTag("verification-custom_recovery_passphrase") + val customRecoveryPassphraseConfirm = TestTag("verification-custom_recovery_passphrase_confirm") /** * Sign out screen.