mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 16:10:00 +00:00
Add custom recovery passphrase setup flow
When the homeserver advertises custom_recovery_passphrase_settings (and the enterprise gate is enabled), the secure-backup setup lets the user enter and confirm their own recovery key with a live strength indicator, instead of receiving a generated one. The SDK derives the 4S key from the passphrase and the base58 key is scrubbed everywhere so it is never shown. Falls back to the generated-key flow when no spec is present or the well-known fetch fails. Snapshot PNGs are intentionally left out; the core team regenerates them after the PR is opened. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,9 +38,12 @@ dependencies {
|
|||||||
implementation(projects.libraries.oauth.api)
|
implementation(projects.libraries.oauth.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
|
implementation(projects.libraries.wellknown.api)
|
||||||
api(libs.statemachine)
|
api(libs.statemachine)
|
||||||
api(projects.features.securebackup.api)
|
api(projects.features.securebackup.api)
|
||||||
|
|
||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
|
testImplementation(projects.features.enterprise.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.wellknown.test)
|
||||||
}
|
}
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.securebackup.impl.setup
|
||||||
|
|
||||||
|
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation flags computed once and shared by the presenter and the preview provider.
|
||||||
|
*
|
||||||
|
* Note: passphrase strength is intentionally NOT derived here. The estimation algorithm is an
|
||||||
|
* enterprise capability ([io.element.android.features.enterprise.api.EnterpriseService.estimateCustomRecoveryPassphraseStrength]);
|
||||||
|
* the presenter computes it via the injected service and the state provider supplies samples directly.
|
||||||
|
*/
|
||||||
|
internal data class CustomPassphraseDerivations(
|
||||||
|
val meetsMinLength: Boolean,
|
||||||
|
val mismatch: Boolean,
|
||||||
|
val canContinueFromEntry: Boolean,
|
||||||
|
val canSubmitCustomPassphrase: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun deriveCustomPassphraseState(
|
||||||
|
requirements: CustomRecoveryPassphraseRequirements?,
|
||||||
|
passphrase: String,
|
||||||
|
confirm: String,
|
||||||
|
step: CustomEntryStep,
|
||||||
|
setupState: SetupState,
|
||||||
|
): CustomPassphraseDerivations {
|
||||||
|
val meetsMinLength = requirements?.isSatisfiedBy(passphrase) ?: true
|
||||||
|
val mismatch = confirm.isNotEmpty() && passphrase != confirm
|
||||||
|
val canContinueFromEntry = requirements != null &&
|
||||||
|
passphrase.isNotEmpty() &&
|
||||||
|
meetsMinLength &&
|
||||||
|
setupState is SetupState.Init
|
||||||
|
val canSubmit = requirements != null &&
|
||||||
|
passphrase.isNotEmpty() &&
|
||||||
|
passphrase == confirm &&
|
||||||
|
meetsMinLength &&
|
||||||
|
step == CustomEntryStep.Confirm &&
|
||||||
|
setupState is SetupState.Init
|
||||||
|
return CustomPassphraseDerivations(
|
||||||
|
meetsMinLength = meetsMinLength,
|
||||||
|
mismatch = mismatch,
|
||||||
|
canContinueFromEntry = canContinueFromEntry,
|
||||||
|
canSubmitCustomPassphrase = canSubmit,
|
||||||
|
)
|
||||||
|
}
|
||||||
+18
@@ -13,4 +13,22 @@ sealed interface SecureBackupSetupEvents {
|
|||||||
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents
|
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents
|
||||||
data object Done : SecureBackupSetupEvents
|
data object Done : SecureBackupSetupEvents
|
||||||
data object DismissDialog : 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
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-39
@@ -11,6 +11,7 @@
|
|||||||
package io.element.android.features.securebackup.impl.setup
|
package io.element.android.features.securebackup.impl.setup
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -22,22 +23,33 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
|||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
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.loggerTagSetup
|
||||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
|
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.RecoveryKeyViewState
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.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.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
private data class WellknownStatus(
|
||||||
|
val loaded: Boolean,
|
||||||
|
val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?,
|
||||||
|
)
|
||||||
|
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class SecureBackupSetupPresenter(
|
class SecureBackupSetupPresenter(
|
||||||
@Assisted private val isChangeRecoveryKeyUserStory: Boolean,
|
@Assisted private val isChangeRecoveryKeyUserStory: Boolean,
|
||||||
private val stateMachine: SecureBackupSetupStateMachine,
|
private val stateMachine: SecureBackupSetupStateMachine,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
|
private val sessionWellknownRetriever: SessionWellknownRetriever,
|
||||||
|
private val enterpriseService: EnterpriseService,
|
||||||
) : Presenter<SecureBackupSetupState> {
|
) : Presenter<SecureBackupSetupState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
@@ -53,10 +65,81 @@ class SecureBackupSetupPresenter(
|
|||||||
}
|
}
|
||||||
var showSaveConfirmationDialog by remember { mutableStateOf(false) }
|
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) {
|
fun handleEvent(event: SecureBackupSetupEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
SecureBackupSetupEvents.CreateRecoveryKey -> {
|
SecureBackupSetupEvents.CreateRecoveryKey -> {
|
||||||
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch)
|
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch, passphrase = null)
|
||||||
}
|
}
|
||||||
SecureBackupSetupEvents.RecoveryKeyHasBeenSaved ->
|
SecureBackupSetupEvents.RecoveryKeyHasBeenSaved ->
|
||||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
|
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
|
||||||
@@ -67,12 +150,51 @@ class SecureBackupSetupPresenter(
|
|||||||
SecureBackupSetupEvents.Done -> {
|
SecureBackupSetupEvents.Done -> {
|
||||||
showSaveConfirmationDialog = true
|
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(
|
val recoveryKeyViewState = RecoveryKeyViewState(
|
||||||
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
|
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
|
||||||
formattedRecoveryKey = setupState.recoveryKey(),
|
// Custom flow: never surface the SDK base58 key in view state, even during the
|
||||||
|
// brief Created/CreatedAndSaved auto-skip window.
|
||||||
|
formattedRecoveryKey = if (isCustomFlow) null else setupState.recoveryKey(),
|
||||||
displayTextFieldContents = true,
|
displayTextFieldContents = true,
|
||||||
inProgress = setupState is SetupState.Creating,
|
inProgress = setupState is SetupState.Creating,
|
||||||
)
|
)
|
||||||
@@ -82,6 +204,16 @@ class SecureBackupSetupPresenter(
|
|||||||
recoveryKeyViewState = recoveryKeyViewState,
|
recoveryKeyViewState = recoveryKeyViewState,
|
||||||
setupState = setupState,
|
setupState = setupState,
|
||||||
showSaveConfirmationDialog = showSaveConfirmationDialog,
|
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,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -98,47 +230,43 @@ class SecureBackupSetupPresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.createOrChangeRecoveryKey(
|
private fun CoroutineScope.createOrChangeRecoveryKey(
|
||||||
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>
|
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>,
|
||||||
|
passphrase: String?,
|
||||||
|
onSuccess: () -> Unit = {},
|
||||||
) = launch {
|
) = launch {
|
||||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey)
|
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey)
|
||||||
if (isChangeRecoveryKeyUserStory) {
|
// Custom passphrase → enableRecovery(passphrase): the SDK derives the 4S key from it.
|
||||||
|
// For the Change flow this rotates in place — the SDK skips backup creation when recovery
|
||||||
|
// is already enabled (confirmed), so there's no key-backup teardown or room-key re-upload.
|
||||||
|
val result = if (passphrase != null) {
|
||||||
|
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=present)")
|
||||||
|
encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = passphrase)
|
||||||
|
} else if (isChangeRecoveryKeyUserStory) {
|
||||||
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()")
|
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()")
|
||||||
encryptionService.resetRecoveryKey().fold(
|
encryptionService.resetRecoveryKey()
|
||||||
onSuccess = {
|
|
||||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it))
|
|
||||||
},
|
|
||||||
onFailure = {
|
|
||||||
if (it is Exception) {
|
|
||||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
observeEncryptionService(stateAndDispatch)
|
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=absent)")
|
||||||
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()")
|
encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = null)
|
||||||
encryptionService.enableRecovery(waitForBackupsToUpload = false).onFailure {
|
}
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { key ->
|
||||||
|
// Clear buffers only on success; on failure keep them so the user can retry
|
||||||
|
// without retyping.
|
||||||
|
onSuccess()
|
||||||
|
// Custom flow: scrub the SDK base58 key from the state machine so SetupState
|
||||||
|
// never carries it. RustEncryptionService likewise keeps it out of
|
||||||
|
// enableRecoveryProgressStateFlow for the passphrase path, so it is not retained
|
||||||
|
// anywhere once this local goes out of scope.
|
||||||
|
val storedKey = if (passphrase != null) "" else key
|
||||||
|
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(storedKey))
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery")
|
Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery")
|
||||||
if (it is Exception) {
|
// The state machine only accepts Exception; wrap anything else so a failure
|
||||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
|
// still leaves the Creating spinner instead of hanging on it.
|
||||||
}
|
val exception = it as? Exception ?: RuntimeException(it)
|
||||||
|
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(exception))
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private fun CoroutineScope.observeEncryptionService(
|
|
||||||
stateAndDispatch: StateAndDispatch<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,16 +8,40 @@
|
|||||||
|
|
||||||
package io.element.android.features.securebackup.impl.setup
|
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.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||||
|
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
|
||||||
|
|
||||||
data class SecureBackupSetupState(
|
data class SecureBackupSetupState(
|
||||||
val isChangeRecoveryKeyUserStory: Boolean,
|
val isChangeRecoveryKeyUserStory: Boolean,
|
||||||
val recoveryKeyViewState: RecoveryKeyViewState,
|
val recoveryKeyViewState: RecoveryKeyViewState,
|
||||||
val showSaveConfirmationDialog: Boolean,
|
val showSaveConfirmationDialog: Boolean,
|
||||||
val setupState: SetupState,
|
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
|
val eventSink: (SecureBackupSetupEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class CustomEntryStep {
|
||||||
|
Entry,
|
||||||
|
Confirm,
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface SetupState {
|
sealed interface SetupState {
|
||||||
data object Init : SetupState
|
data object Init : SetupState
|
||||||
data object Creating : SetupState
|
data object Creating : SetupState
|
||||||
|
|||||||
+6
@@ -34,6 +34,9 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine<SecureBackupSetupSta
|
|||||||
on { event: Event.SdkHasCreatedKey, state: MachineState<State.CreatingKey> ->
|
on { event: Event.SdkHasCreatedKey, state: MachineState<State.CreatingKey> ->
|
||||||
state.override { State.KeyCreated(event.key) }
|
state.override { State.KeyCreated(event.key) }
|
||||||
}
|
}
|
||||||
|
on { _: Event.UserCancelledCreate, state: MachineState<State.CreatingKey> ->
|
||||||
|
state.override { State.Initial }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
inState<State.KeyCreated> {
|
inState<State.KeyCreated> {
|
||||||
on { _: Event.UserSavedKey, state: MachineState<State.KeyCreated> ->
|
on { _: Event.UserSavedKey, state: MachineState<State.KeyCreated> ->
|
||||||
@@ -64,5 +67,8 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine<SecureBackupSetupSta
|
|||||||
data class SdkError(val exception: Exception) : Event
|
data class SdkError(val exception: Exception) : Event
|
||||||
data object UserSavedKey : Event
|
data object UserSavedKey : Event
|
||||||
data object ClearError : 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-10
@@ -9,13 +9,17 @@
|
|||||||
package io.element.android.features.securebackup.impl.setup
|
package io.element.android.features.securebackup.impl.setup
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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.RecoveryKeyUserStory
|
||||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||||
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
|
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
|
||||||
|
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
|
||||||
|
|
||||||
open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBackupSetupState> {
|
open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBackupSetupState> {
|
||||||
override val values: Sequence<SecureBackupSetupState>
|
override val values: Sequence<SecureBackupSetupState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
|
aSecureBackupSetupState(wellknownLoaded = false),
|
||||||
aSecureBackupSetupState(setupState = SetupState.Init),
|
aSecureBackupSetupState(setupState = SetupState.Init),
|
||||||
aSecureBackupSetupState(setupState = SetupState.Creating),
|
aSecureBackupSetupState(setupState = SetupState.Creating),
|
||||||
aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())),
|
aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())),
|
||||||
@@ -25,25 +29,141 @@ open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBacku
|
|||||||
showSaveConfirmationDialog = true,
|
showSaveConfirmationDialog = true,
|
||||||
),
|
),
|
||||||
aSecureBackupSetupState(setupState = SetupState.Error(Exception("Test error"))),
|
aSecureBackupSetupState(setupState = SetupState.Error(Exception("Test error"))),
|
||||||
// Add other states here
|
// 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")),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aSecureBackupSetupState(
|
fun aSecureBackupSetupState(
|
||||||
|
isChangeRecoveryKeyUserStory: Boolean = false,
|
||||||
setupState: SetupState = SetupState.Init,
|
setupState: SetupState = SetupState.Init,
|
||||||
showSaveConfirmationDialog: Boolean = false,
|
showSaveConfirmationDialog: Boolean = false,
|
||||||
) = SecureBackupSetupState(
|
wellknownLoaded: Boolean = true,
|
||||||
isChangeRecoveryKeyUserStory = false,
|
customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements? = null,
|
||||||
setupState = setupState,
|
customEntryStep: CustomEntryStep = CustomEntryStep.Entry,
|
||||||
showSaveConfirmationDialog = showSaveConfirmationDialog,
|
customPassphrase: String = "",
|
||||||
recoveryKeyViewState = setupState.toRecoveryKeyViewState(),
|
customPassphraseConfirm: String = "",
|
||||||
eventSink = {}
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState {
|
private fun SetupState.toRecoveryKeyViewState(
|
||||||
|
isChangeRecoveryKeyUserStory: Boolean,
|
||||||
|
suppressKey: Boolean,
|
||||||
|
): RecoveryKeyViewState {
|
||||||
return RecoveryKeyViewState(
|
return RecoveryKeyViewState(
|
||||||
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
|
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
|
||||||
formattedRecoveryKey = recoveryKey(),
|
// Match the presenter — the custom-passphrase flow never surfaces the SDK base58 key.
|
||||||
|
formattedRecoveryKey = if (suppressKey) null else recoveryKey(),
|
||||||
displayTextFieldContents = true,
|
displayTextFieldContents = true,
|
||||||
inProgress = this is SetupState.Creating,
|
inProgress = this is SetupState.Creating,
|
||||||
)
|
)
|
||||||
|
|||||||
+367
-11
@@ -8,16 +8,43 @@
|
|||||||
|
|
||||||
package io.element.android.features.securebackup.impl.setup
|
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.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.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.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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
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.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.R
|
||||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
|
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
|
||||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||||
@@ -29,8 +56,17 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
|||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
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.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.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
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -40,13 +76,19 @@ fun SecureBackupSetupView(
|
|||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
FlowStepPage(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackClick = onBackClick.takeIf { state.canGoBack() },
|
onBackClick = backClickHandler(state, onBackClick),
|
||||||
title = title(state),
|
title = title(state),
|
||||||
subTitle = subtitle(state),
|
subTitle = subtitle(state),
|
||||||
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
|
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
|
||||||
buttons = { Buttons(state, onFinish = onSuccess) },
|
buttons = { Buttons(state, onFinish = onSuccess, onCancel = onBackClick) },
|
||||||
) {
|
) {
|
||||||
Content(state = state)
|
Content(state = state)
|
||||||
}
|
}
|
||||||
@@ -78,15 +120,45 @@ private fun SecureBackupSetupState.canGoBack(): Boolean {
|
|||||||
return recoveryKeyViewState.formattedRecoveryKey == null
|
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
|
@Composable
|
||||||
private fun title(state: SecureBackupSetupState): String {
|
private fun title(state: SecureBackupSetupState): String {
|
||||||
|
// Hide the heading until the well-known resolves, so it can't flip copy under the user.
|
||||||
|
if (!state.wellknownLoaded) return ""
|
||||||
|
// Custom flow has no "save your key" step — use per-step custom titles throughout.
|
||||||
|
if (state.isCustomEntry()) {
|
||||||
|
return when (state.customEntryStep) {
|
||||||
|
CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_title)
|
||||||
|
CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_title)
|
||||||
|
}
|
||||||
|
}
|
||||||
return when (state.setupState) {
|
return when (state.setupState) {
|
||||||
SetupState.Init,
|
SetupState.Init,
|
||||||
SetupState.Creating,
|
SetupState.Creating,
|
||||||
is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) {
|
is SetupState.Error -> when {
|
||||||
stringResource(id = R.string.screen_recovery_key_change_title)
|
state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_title)
|
||||||
} else {
|
else -> stringResource(id = R.string.screen_recovery_key_setup_title)
|
||||||
stringResource(id = R.string.screen_recovery_key_setup_title)
|
|
||||||
}
|
}
|
||||||
is SetupState.Created,
|
is SetupState.Created,
|
||||||
is SetupState.CreatedAndSaved ->
|
is SetupState.CreatedAndSaved ->
|
||||||
@@ -95,14 +167,20 @@ private fun title(state: SecureBackupSetupState): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun subtitle(state: SecureBackupSetupState): String {
|
private fun subtitle(state: SecureBackupSetupState): String? {
|
||||||
|
if (!state.wellknownLoaded) return null
|
||||||
|
if (state.isCustomEntry()) {
|
||||||
|
return when (state.customEntryStep) {
|
||||||
|
CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_description)
|
||||||
|
CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_description)
|
||||||
|
}
|
||||||
|
}
|
||||||
return when (state.setupState) {
|
return when (state.setupState) {
|
||||||
SetupState.Init,
|
SetupState.Init,
|
||||||
SetupState.Creating,
|
SetupState.Creating,
|
||||||
is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) {
|
is SetupState.Error -> when {
|
||||||
stringResource(id = R.string.screen_recovery_key_change_description)
|
state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_description)
|
||||||
} else {
|
else -> stringResource(id = R.string.screen_recovery_key_setup_description)
|
||||||
stringResource(id = R.string.screen_recovery_key_setup_description)
|
|
||||||
}
|
}
|
||||||
is SetupState.Created,
|
is SetupState.Created,
|
||||||
is SetupState.CreatedAndSaved ->
|
is SetupState.CreatedAndSaved ->
|
||||||
@@ -114,6 +192,25 @@ private fun subtitle(state: SecureBackupSetupState): String {
|
|||||||
private fun Content(
|
private fun Content(
|
||||||
state: SecureBackupSetupState,
|
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 context = LocalContext.current
|
||||||
val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey
|
val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey
|
||||||
val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard)
|
val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard)
|
||||||
@@ -144,10 +241,269 @@ private fun Content(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingPlaceholder() {
|
||||||
|
val description = stringResource(id = R.string.a11y_recovery_key_loading_specs)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 52.dp)
|
||||||
|
.semantics { contentDescription = description },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
// CircularProgressIndicator applies progressSemantics() internally — no need to add it here.
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CustomPassphraseEntry(state: SecureBackupSetupState) {
|
||||||
|
val specs = state.customRecoveryPassphraseRequirements ?: return
|
||||||
|
var passphraseVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(TestTags.customRecoveryPassphrase)
|
||||||
|
.semantics { contentType = ContentType.NewPassword },
|
||||||
|
value = state.customPassphrase,
|
||||||
|
onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(it)) },
|
||||||
|
label = stringResource(id = CommonStrings.common_recovery_key),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
PasswordVisibilityToggle(
|
||||||
|
visible = passphraseVisible,
|
||||||
|
onToggle = { passphraseVisible = !passphraseVisible },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
if (state.canContinueFromEntry) {
|
||||||
|
state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
supportingText = pluralStringResource(
|
||||||
|
id = R.plurals.screen_recovery_key_custom_requirement_length,
|
||||||
|
count = specs.minCharacterCount,
|
||||||
|
specs.minCharacterCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Always render the strength row in the Entry step (matches iOS): an empty field shows the
|
||||||
|
// "Strength" base label with an empty bar; typing flips it to the per-level label + bar.
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
PassphraseStrengthIndicator(
|
||||||
|
result = state.customPassphraseStrength,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PassphraseStrengthIndicator(
|
||||||
|
result: CustomRecoveryPassphraseStrengthResult?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val label = stringResource(
|
||||||
|
id = result?.strength?.labelRes() ?: R.string.screen_recovery_key_custom_strength_base,
|
||||||
|
)
|
||||||
|
val hint = result?.strength?.hintRes()?.let { stringResource(id = it) }
|
||||||
|
val score = result?.score ?: 0f
|
||||||
|
// Mirror iOS: a zero score (empty field or the Garbage tier) stays neutral; otherwise the
|
||||||
|
// label and bar share a single colour interpolated along the red→orange→yellow→green gradient.
|
||||||
|
val color = if (score <= 0f) ElementTheme.colors.textSecondary else passphraseStrengthColor(score)
|
||||||
|
val announcement = stringResource(id = R.string.a11y_recovery_key_custom_strength_announcement, label)
|
||||||
|
.let { if (hint != null) "$it. $hint" else it }
|
||||||
|
Column(
|
||||||
|
modifier = modifier.semantics(mergeDescendants = true) {
|
||||||
|
contentDescription = announcement
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = color,
|
||||||
|
style = ElementTheme.typography.fontBodySmMedium,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { score },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
if (hint != null) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = hint,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CustomRecoveryPassphraseStrength.labelRes(): Int = when (this) {
|
||||||
|
CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_garbage
|
||||||
|
CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_weak
|
||||||
|
CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_moderate
|
||||||
|
CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_okay
|
||||||
|
CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_strong
|
||||||
|
CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_very_strong
|
||||||
|
CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_ultra_strong
|
||||||
|
CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_mega
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CustomRecoveryPassphraseStrength.hintRes(): Int = when (this) {
|
||||||
|
CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_hint_garbage
|
||||||
|
CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_hint_weak
|
||||||
|
CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_hint_moderate
|
||||||
|
CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_hint_okay
|
||||||
|
CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_hint_strong
|
||||||
|
CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_hint_very_strong
|
||||||
|
CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_hint_ultra_strong
|
||||||
|
CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_hint_mega
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops mirror iOS PasswordStrengthBar.gradient: Compound red900 / lime700 with the same vivid
|
||||||
|
// orange & yellow midpoints. Intentionally fixed (not light/dark semantic) so the gradient reads
|
||||||
|
// identically to iOS in both themes.
|
||||||
|
private val passphraseStrengthGradientStops = listOf(
|
||||||
|
0.25f to Color(0xFFD51928), // Compound red900
|
||||||
|
0.5f to Color(0xFFFF9500), // orange
|
||||||
|
0.75f to Color(0xFFFFCC00), // yellow
|
||||||
|
1.0f to Color(0xFF54C424), // Compound lime700
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Interpolates the strength bar colour along [passphraseStrengthGradientStops] for a 0f..1f score. */
|
||||||
|
private fun passphraseStrengthColor(score: Float): Color {
|
||||||
|
val clamped = score.coerceIn(0f, 1f)
|
||||||
|
var previousLocation = 0f
|
||||||
|
var previousColor = passphraseStrengthGradientStops.first().second
|
||||||
|
for ((location, color) in passphraseStrengthGradientStops) {
|
||||||
|
if (clamped <= location) {
|
||||||
|
val span = location - previousLocation
|
||||||
|
if (span <= 0f) return color
|
||||||
|
return lerp(previousColor, color, ((clamped - previousLocation) / span).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
previousLocation = location
|
||||||
|
previousColor = color
|
||||||
|
}
|
||||||
|
return previousColor
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CustomPassphraseConfirm(state: SecureBackupSetupState) {
|
||||||
|
var passphraseVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 24.dp),
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(TestTags.customRecoveryPassphraseConfirm)
|
||||||
|
.semantics { contentType = ContentType.NewPassword },
|
||||||
|
value = state.customPassphraseConfirm,
|
||||||
|
onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(it)) },
|
||||||
|
label = stringResource(id = CommonStrings.common_recovery_key),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
PasswordVisibilityToggle(
|
||||||
|
visible = passphraseVisible,
|
||||||
|
onToggle = { passphraseVisible = !passphraseVisible },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
if (state.canSubmitCustomPassphrase) {
|
||||||
|
state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
validity = if (state.customPassphraseMismatch) TextFieldValidity.Invalid else TextFieldValidity.None,
|
||||||
|
supportingText = if (state.customPassphraseMismatch) {
|
||||||
|
stringResource(id = R.string.screen_recovery_key_custom_mismatch)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.Buttons(
|
private fun ColumnScope.Buttons(
|
||||||
state: SecureBackupSetupState,
|
state: SecureBackupSetupState,
|
||||||
onFinish: () -> Unit,
|
onFinish: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
// No buttons until the well-known resolves; Content shows a spinner meanwhile.
|
||||||
|
if (!state.wellknownLoaded && state.setupState == SetupState.Init) return
|
||||||
|
if (state.isCustomEntry()) {
|
||||||
|
CustomButtons(state = state, onCancel = onCancel)
|
||||||
|
} else {
|
||||||
|
AutoGenButtons(state = state, onFinish = onFinish)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.CustomButtons(
|
||||||
|
state: SecureBackupSetupState,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
// No save/share controls in the custom flow — they'd leak the base58 key during Created.
|
||||||
|
when (state.setupState) {
|
||||||
|
SetupState.Creating,
|
||||||
|
is SetupState.Created,
|
||||||
|
is SetupState.CreatedAndSaved -> return
|
||||||
|
SetupState.Init,
|
||||||
|
is SetupState.Error -> Unit
|
||||||
|
}
|
||||||
|
when (state.customEntryStep) {
|
||||||
|
CustomEntryStep.Entry -> {
|
||||||
|
Button(
|
||||||
|
text = stringResource(id = CommonStrings.action_continue),
|
||||||
|
enabled = state.canContinueFromEntry,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) },
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(id = CommonStrings.action_cancel),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = onCancel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CustomEntryStep.Confirm -> {
|
||||||
|
Button(
|
||||||
|
text = stringResource(id = R.string.screen_recovery_key_custom_finish_action),
|
||||||
|
enabled = state.canSubmitCustomPassphrase,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.AutoGenButtons(
|
||||||
|
state: SecureBackupSetupState,
|
||||||
|
onFinish: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action)
|
val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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="screen_recovery_key_custom_title">Enter a custom recovery key</string>
|
||||||
|
<string name="screen_recovery_key_custom_description">Choose a recovery key that you can memorize.</string>
|
||||||
|
<string name="screen_recovery_key_custom_confirm_title">Confirm your recovery key</string>
|
||||||
|
<string name="screen_recovery_key_custom_confirm_description">Enter your recovery key again.</string>
|
||||||
|
<string name="screen_recovery_key_custom_mismatch">The recovery key you entered doesn\'t match</string>
|
||||||
|
<plurals name="screen_recovery_key_custom_requirement_length">
|
||||||
|
<item quantity="one">Minimum %1$d character</item>
|
||||||
|
<item quantity="other">Minimum %1$d characters</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="screen_recovery_key_custom_finish_action">Finish setup</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_base">Strength</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_garbage">Garbage</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_weak">Weak</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_moderate">Moderate</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_okay">Okay</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_strong">Strong</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_very_strong">Very strong</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_ultra_strong">Super strong</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_mega">Ideal</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_hint_garbage">Cracking would take roughly milliseconds.</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_hint_weak">Cracking would take roughly minutes to months.</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_hint_moderate">Cracking would take roughly years.</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_hint_okay">Cracking would take roughly hundreds of years.</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_hint_strong">Cracking would take roughly billions of years.</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_hint_very_strong">Cracking would take roughly trillions of years.</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_hint_ultra_strong">Cracking would take roughly quadrillions of years.</string>
|
||||||
|
<string name="screen_recovery_key_custom_strength_hint_mega">Cracking would take roughly quintillions of years.</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>
|
||||||
+747
-11
@@ -6,23 +6,37 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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
|
package io.element.android.features.securebackup.impl.setup
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.RecoveryKeyUserStory
|
||||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
import io.element.android.features.wellknown.test.FakeSessionWellknownRetriever
|
||||||
|
import io.element.android.features.wellknown.test.aCustomRecoveryPassphraseRequirements
|
||||||
|
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.test.A_RECOVERY_KEY
|
import io.element.android.libraries.matrix.test.A_RECOVERY_KEY
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
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.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 kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
@Suppress("LargeClass")
|
||||||
class SecureBackupSetupPresenterTest {
|
class SecureBackupSetupPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
@@ -33,11 +47,16 @@ class SecureBackupSetupPresenterTest {
|
|||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val preFetch = awaitItem()
|
||||||
assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse()
|
assertThat(preFetch.wellknownLoaded).isFalse()
|
||||||
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
|
assertThat(preFetch.setupState).isEqualTo(SetupState.Init)
|
||||||
assertThat(initialState.showSaveConfirmationDialog).isFalse()
|
|
||||||
assertThat(initialState.recoveryKeyViewState).isEqualTo(
|
val loaded = awaitItem()
|
||||||
|
assertThat(loaded.wellknownLoaded).isTrue()
|
||||||
|
assertThat(loaded.isChangeRecoveryKeyUserStory).isFalse()
|
||||||
|
assertThat(loaded.setupState).isEqualTo(SetupState.Init)
|
||||||
|
assertThat(loaded.showSaveConfirmationDialog).isFalse()
|
||||||
|
assertThat(loaded.recoveryKeyViewState).isEqualTo(
|
||||||
RecoveryKeyViewState(
|
RecoveryKeyViewState(
|
||||||
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
|
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
|
||||||
formattedRecoveryKey = null,
|
formattedRecoveryKey = null,
|
||||||
@@ -50,14 +69,17 @@ class SecureBackupSetupPresenterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - create recovery key and save it`() = runTest {
|
fun `present - create recovery key and save it`() = runTest {
|
||||||
val encryptionService = FakeEncryptionService()
|
val encryptionService = FakeEncryptionService(
|
||||||
|
enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) },
|
||||||
|
)
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
val presenter = createSecureBackupSetupPresenter(
|
||||||
encryptionService = encryptionService
|
encryptionService = encryptionService
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
awaitItem() // pre-fetch
|
||||||
|
val initialState = awaitItem() // post-fetch, wellknownLoaded=true
|
||||||
initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
|
initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
|
||||||
val creatingState = awaitItem()
|
val creatingState = awaitItem()
|
||||||
assertThat(creatingState.setupState).isEqualTo(SetupState.Creating)
|
assertThat(creatingState.setupState).isEqualTo(SetupState.Creating)
|
||||||
@@ -69,7 +91,6 @@ class SecureBackupSetupPresenterTest {
|
|||||||
inProgress = true,
|
inProgress = true,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
|
|
||||||
val createdState = awaitItem()
|
val createdState = awaitItem()
|
||||||
assertThat(createdState.setupState).isEqualTo(SetupState.Created(A_RECOVERY_KEY))
|
assertThat(createdState.setupState).isEqualTo(SetupState.Created(A_RECOVERY_KEY))
|
||||||
assertThat(createdState.recoveryKeyViewState).isEqualTo(
|
assertThat(createdState.recoveryKeyViewState).isEqualTo(
|
||||||
@@ -100,7 +121,9 @@ class SecureBackupSetupPresenterTest {
|
|||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
awaitItem() // pre-fetch
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.wellknownLoaded).isTrue()
|
||||||
assertThat(initialState.isChangeRecoveryKeyUserStory).isTrue()
|
assertThat(initialState.isChangeRecoveryKeyUserStory).isTrue()
|
||||||
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
|
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
|
||||||
assertThat(initialState.recoveryKeyViewState).isEqualTo(
|
assertThat(initialState.recoveryKeyViewState).isEqualTo(
|
||||||
@@ -117,7 +140,7 @@ class SecureBackupSetupPresenterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `present - handle errors`() = runTest {
|
fun `present - handle errors`() = runTest {
|
||||||
val encryptionService = FakeEncryptionService(
|
val encryptionService = FakeEncryptionService(
|
||||||
enableRecoveryLambda = { Result.failure(IllegalStateException("Test error")) }
|
enableRecoveryLambda = { _, _ -> Result.failure(IllegalStateException("Test error")) }
|
||||||
)
|
)
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
val presenter = createSecureBackupSetupPresenter(
|
||||||
isChangeRecoveryKeyUserStory = false,
|
isChangeRecoveryKeyUserStory = false,
|
||||||
@@ -126,6 +149,7 @@ class SecureBackupSetupPresenterTest {
|
|||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
awaitItem() // pre-fetch
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse()
|
assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse()
|
||||||
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
|
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
|
||||||
@@ -152,6 +176,7 @@ class SecureBackupSetupPresenterTest {
|
|||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
awaitItem() // pre-fetch
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
|
initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
|
||||||
val creatingState = awaitItem()
|
val creatingState = awaitItem()
|
||||||
@@ -186,16 +211,727 @@ class SecureBackupSetupPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - wellknownLoaded flips false to true exactly once after fetch resolves`() = runTest {
|
||||||
|
val presenter = createSecureBackupSetupPresenter(
|
||||||
|
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
||||||
|
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val preFetch = awaitItem()
|
||||||
|
assertThat(preFetch.wellknownLoaded).isFalse()
|
||||||
|
assertThat(preFetch.customRecoveryPassphraseRequirements).isNull()
|
||||||
|
|
||||||
|
val loaded = awaitItem()
|
||||||
|
assertThat(loaded.wellknownLoaded).isTrue()
|
||||||
|
assertThat(loaded.customRecoveryPassphraseRequirements).isNotNull()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - custom spec absent leaves customRecoveryPassphraseRequirements null and uses generated-key path`() = runTest {
|
||||||
|
val enableRecoveryLambda = lambdaRecorder<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(
|
private fun createSecureBackupSetupPresenter(
|
||||||
isChangeRecoveryKeyUserStory: Boolean = false,
|
isChangeRecoveryKeyUserStory: Boolean = false,
|
||||||
encryptionService: EncryptionService = FakeEncryptionService(
|
encryptionService: EncryptionService = FakeEncryptionService(
|
||||||
enableRecoveryLambda = { Result.success(Unit) },
|
enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) },
|
||||||
),
|
),
|
||||||
|
sessionWellknownRetriever: SessionWellknownRetriever = FakeSessionWellknownRetriever(),
|
||||||
|
enterpriseService: EnterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = true),
|
||||||
): SecureBackupSetupPresenter {
|
): SecureBackupSetupPresenter {
|
||||||
return SecureBackupSetupPresenter(
|
return SecureBackupSetupPresenter(
|
||||||
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
|
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
|
||||||
stateMachine = SecureBackupSetupStateMachine(),
|
stateMachine = SecureBackupSetupStateMachine(),
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
|
sessionWellknownRetriever = sessionWellknownRetriever,
|
||||||
|
enterpriseService = enterpriseService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ object TestTags {
|
|||||||
* Verification screen.
|
* Verification screen.
|
||||||
*/
|
*/
|
||||||
val recoveryKey = TestTag("verification-recovery_key")
|
val recoveryKey = TestTag("verification-recovery_key")
|
||||||
|
val customRecoveryPassphrase = TestTag("verification-custom_recovery_passphrase")
|
||||||
|
val customRecoveryPassphraseConfirm = TestTag("verification-custom_recovery_passphrase_confirm")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign out screen.
|
* Sign out screen.
|
||||||
|
|||||||
Reference in New Issue
Block a user