diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts index d7dafc3e65..54d87ef22e 100644 --- a/features/securebackup/impl/build.gradle.kts +++ b/features/securebackup/impl/build.gradle.kts @@ -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) } 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 deleted file mode 100644 index 7bf6e5805c..0000000000 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt +++ /dev/null @@ -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, - ) -} 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 faf3d618fe..f61e65ba61 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,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 } 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 9145b4a49b..9f27bc9566 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,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 { @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(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, - passphrase: String?, - onSuccess: () -> Unit = {}, + stateAndDispatch: StateAndDispatch ) = 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 + ) = 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 0e5b60dab7..752b5e4851 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,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 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 62478e3b87..150aeeddc7 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,9 +34,6 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine -> state.override { State.KeyCreated(event.key) } } - on { _: Event.UserCancelledCreate, state: MachineState -> - state.override { State.Initial } - } } inState { on { _: Event.UserSavedKey, state: MachineState -> @@ -67,8 +64,5 @@ 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())), @@ -29,141 +25,25 @@ 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 = 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) diff --git a/features/securebackup/impl/src/main/res/values/temporary.xml b/features/securebackup/impl/src/main/res/values/temporary.xml deleted file mode 100644 index c573a4c6e3..0000000000 --- a/features/securebackup/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - Enter a recovery key - Choose a recovery key that you can memorize. - Confirm your recovery key - Enter your recovery key again. - Minimum %1$s 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. - - The recovery key you entered doesn\'t match - 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 6ca716bcd6..4aede3c534 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,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> { _, _ -> 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(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, ) } }