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