mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 16:10:00 +00:00
Remove custom recovery passphrase flow from FOSS secure backup setup
The custom passphrase entry/confirm UI now lives in the enterprise secure backup module behind the SecureBackupSetupEntryPoint seam. Reverts the setup presenter, view, state, state machine, state provider, and events to the auto-generated-key-only flow, deletes CustomPassphraseDerivations and the temporary strings, and drops the well-known and enterprise-test dependencies the feature required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,12 +38,9 @@ dependencies {
|
|||||||
implementation(projects.libraries.oauth.api)
|
implementation(projects.libraries.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
@@ -1,51 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2026 Element Creations Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.securebackup.impl.setup
|
|
||||||
|
|
||||||
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation flags computed once and shared by the presenter and the preview provider.
|
|
||||||
*
|
|
||||||
* Note: passphrase strength is intentionally NOT derived here. The estimation algorithm is an
|
|
||||||
* enterprise capability ([io.element.android.features.enterprise.api.EnterpriseService.estimateCustomRecoveryPassphraseStrength]);
|
|
||||||
* the presenter computes it via the injected service and the state provider supplies samples directly.
|
|
||||||
*/
|
|
||||||
internal data class CustomPassphraseDerivations(
|
|
||||||
val meetsMinLength: Boolean,
|
|
||||||
val mismatch: Boolean,
|
|
||||||
val canContinueFromEntry: Boolean,
|
|
||||||
val canSubmitCustomPassphrase: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun deriveCustomPassphraseState(
|
|
||||||
requirements: CustomRecoveryPassphraseRequirements?,
|
|
||||||
passphrase: String,
|
|
||||||
confirm: String,
|
|
||||||
step: CustomEntryStep,
|
|
||||||
setupState: SetupState,
|
|
||||||
): CustomPassphraseDerivations {
|
|
||||||
val meetsMinLength = requirements?.isSatisfiedBy(passphrase) ?: true
|
|
||||||
val mismatch = confirm.isNotEmpty() && passphrase != confirm
|
|
||||||
val canContinueFromEntry = requirements != null &&
|
|
||||||
passphrase.isNotEmpty() &&
|
|
||||||
meetsMinLength &&
|
|
||||||
setupState is SetupState.Init
|
|
||||||
val canSubmit = requirements != null &&
|
|
||||||
passphrase.isNotEmpty() &&
|
|
||||||
passphrase == confirm &&
|
|
||||||
meetsMinLength &&
|
|
||||||
step == CustomEntryStep.Confirm &&
|
|
||||||
setupState is SetupState.Init
|
|
||||||
return CustomPassphraseDerivations(
|
|
||||||
meetsMinLength = meetsMinLength,
|
|
||||||
mismatch = mismatch,
|
|
||||||
canContinueFromEntry = canContinueFromEntry,
|
|
||||||
canSubmitCustomPassphrase = canSubmit,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-18
@@ -13,22 +13,4 @@ sealed interface SecureBackupSetupEvents {
|
|||||||
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents
|
data object 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-167
@@ -11,7 +11,6 @@
|
|||||||
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
|
||||||
@@ -23,33 +22,22 @@ 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 {
|
||||||
@@ -65,81 +53,10 @@ 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, passphrase = null)
|
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch)
|
||||||
}
|
}
|
||||||
SecureBackupSetupEvents.RecoveryKeyHasBeenSaved ->
|
SecureBackupSetupEvents.RecoveryKeyHasBeenSaved ->
|
||||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
|
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
|
||||||
@@ -150,51 +67,12 @@ 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,
|
||||||
// Custom flow: never surface the SDK base58 key in view state, even during the
|
formattedRecoveryKey = setupState.recoveryKey(),
|
||||||
// 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,
|
||||||
)
|
)
|
||||||
@@ -204,16 +82,6 @@ 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -230,43 +98,47 @@ 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)
|
||||||
// Custom passphrase → enableRecovery(passphrase): the SDK derives the 4S key from it.
|
if (isChangeRecoveryKeyUserStory) {
|
||||||
// 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()
|
encryptionService.resetRecoveryKey().fold(
|
||||||
|
onSuccess = {
|
||||||
|
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it))
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
if (it is Exception) {
|
||||||
|
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=absent)")
|
observeEncryptionService(stateAndDispatch)
|
||||||
encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = null)
|
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()")
|
||||||
}
|
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")
|
||||||
// The state machine only accepts Exception; wrap anything else so a failure
|
if (it is Exception) {
|
||||||
// still leaves the Creating spinner instead of hanging on it.
|
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(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,40 +8,16 @@
|
|||||||
|
|
||||||
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,9 +34,6 @@ 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> ->
|
||||||
@@ -67,8 +64,5 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-130
@@ -9,17 +9,13 @@
|
|||||||
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())),
|
||||||
@@ -29,141 +25,25 @@ open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBacku
|
|||||||
showSaveConfirmationDialog = true,
|
showSaveConfirmationDialog = true,
|
||||||
),
|
),
|
||||||
aSecureBackupSetupState(setupState = SetupState.Error(Exception("Test error"))),
|
aSecureBackupSetupState(setupState = SetupState.Error(Exception("Test error"))),
|
||||||
// Custom-entry: empty Entry step
|
// Add other states here
|
||||||
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,
|
||||||
wellknownLoaded: Boolean = true,
|
) = SecureBackupSetupState(
|
||||||
customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements? = null,
|
isChangeRecoveryKeyUserStory = false,
|
||||||
customEntryStep: CustomEntryStep = CustomEntryStep.Entry,
|
setupState = setupState,
|
||||||
customPassphrase: String = "",
|
showSaveConfirmationDialog = showSaveConfirmationDialog,
|
||||||
customPassphraseConfirm: String = "",
|
recoveryKeyViewState = setupState.toRecoveryKeyViewState(),
|
||||||
customPassphraseStrength: CustomRecoveryPassphraseStrengthResult? = null,
|
eventSink = {}
|
||||||
): 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(
|
private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState {
|
||||||
isChangeRecoveryKeyUserStory: Boolean,
|
|
||||||
suppressKey: Boolean,
|
|
||||||
): RecoveryKeyViewState {
|
|
||||||
return RecoveryKeyViewState(
|
return RecoveryKeyViewState(
|
||||||
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
|
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
|
||||||
// Match the presenter — the custom-passphrase flow never surfaces the SDK base58 key.
|
formattedRecoveryKey = recoveryKey(),
|
||||||
formattedRecoveryKey = if (suppressKey) null else recoveryKey(),
|
|
||||||
displayTextFieldContents = true,
|
displayTextFieldContents = true,
|
||||||
inProgress = this is SetupState.Creating,
|
inProgress = this is SetupState.Creating,
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-365
@@ -8,42 +8,16 @@
|
|||||||
|
|
||||||
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.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
|
||||||
@@ -55,17 +29,8 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
|||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.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
|
||||||
@@ -75,19 +40,13 @@ 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 = backClickHandler(state, onBackClick),
|
onBackClick = onBackClick.takeIf { state.canGoBack() },
|
||||||
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, onCancel = onBackClick) },
|
buttons = { Buttons(state, onFinish = onSuccess) },
|
||||||
) {
|
) {
|
||||||
Content(state = state)
|
Content(state = state)
|
||||||
}
|
}
|
||||||
@@ -119,45 +78,15 @@ 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.pro_screen_recovery_key_mode_input_title)
|
|
||||||
CustomEntryStep.Confirm -> stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return when (state.setupState) {
|
return when (state.setupState) {
|
||||||
SetupState.Init,
|
SetupState.Init,
|
||||||
SetupState.Creating,
|
SetupState.Creating,
|
||||||
is SetupState.Error -> when {
|
is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) {
|
||||||
state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_title)
|
stringResource(id = R.string.screen_recovery_key_change_title)
|
||||||
else -> stringResource(id = R.string.screen_recovery_key_setup_title)
|
} else {
|
||||||
|
stringResource(id = R.string.screen_recovery_key_setup_title)
|
||||||
}
|
}
|
||||||
is SetupState.Created,
|
is SetupState.Created,
|
||||||
is SetupState.CreatedAndSaved ->
|
is SetupState.CreatedAndSaved ->
|
||||||
@@ -166,20 +95,14 @@ 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.pro_screen_recovery_key_mode_input_description)
|
|
||||||
CustomEntryStep.Confirm -> stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return when (state.setupState) {
|
return when (state.setupState) {
|
||||||
SetupState.Init,
|
SetupState.Init,
|
||||||
SetupState.Creating,
|
SetupState.Creating,
|
||||||
is SetupState.Error -> when {
|
is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) {
|
||||||
state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_description)
|
stringResource(id = R.string.screen_recovery_key_change_description)
|
||||||
else -> stringResource(id = R.string.screen_recovery_key_setup_description)
|
} else {
|
||||||
|
stringResource(id = R.string.screen_recovery_key_setup_description)
|
||||||
}
|
}
|
||||||
is SetupState.Created,
|
is SetupState.Created,
|
||||||
is SetupState.CreatedAndSaved ->
|
is SetupState.CreatedAndSaved ->
|
||||||
@@ -191,25 +114,6 @@ 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)
|
||||||
@@ -240,268 +144,10 @@ private fun Content(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LoadingPlaceholder() {
|
|
||||||
val description = stringResource(id = R.string.a11y_recovery_key_loading_specs)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 52.dp)
|
|
||||||
.semantics { contentDescription = description },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
// CircularProgressIndicator applies progressSemantics() internally — no need to add it here.
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CustomPassphraseEntry(state: SecureBackupSetupState) {
|
|
||||||
val specs = state.customRecoveryPassphraseRequirements ?: return
|
|
||||||
var passphraseVisible by rememberSaveable { mutableStateOf(false) }
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 24.dp),
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag(TestTags.customRecoveryPassphrase)
|
|
||||||
.semantics { contentType = ContentType.NewPassword },
|
|
||||||
value = state.customPassphrase,
|
|
||||||
onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(it)) },
|
|
||||||
label = stringResource(id = CommonStrings.common_recovery_key),
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
|
||||||
trailingIcon = {
|
|
||||||
PasswordVisibilityToggle(
|
|
||||||
visible = passphraseVisible,
|
|
||||||
onToggle = { passphraseVisible = !passphraseVisible },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
if (state.canContinueFromEntry) {
|
|
||||||
state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
supportingText = stringResource(
|
|
||||||
id = R.string.pro_screen_recovery_key_mode_input_passphrase_field_footer,
|
|
||||||
specs.minCharacterCount,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Always render the strength row in the Entry step (matches iOS): an empty field shows the
|
|
||||||
// "Strength" base label with an empty bar; typing flips it to the per-level label + bar.
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
PassphraseStrengthIndicator(
|
|
||||||
result = state.customPassphraseStrength,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PassphraseStrengthIndicator(
|
|
||||||
result: CustomRecoveryPassphraseStrengthResult?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val label = stringResource(
|
|
||||||
id = result?.strength?.labelRes() ?: R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_base,
|
|
||||||
)
|
|
||||||
val hint = result?.strength?.hintRes()?.let { stringResource(id = it) }
|
|
||||||
val score = result?.score ?: 0f
|
|
||||||
// Mirror iOS: a zero score (empty field or the Garbage tier) stays neutral; otherwise the
|
|
||||||
// label and bar share a single colour interpolated along the red→orange→yellow→green gradient.
|
|
||||||
val color = if (score <= 0f) ElementTheme.colors.textSecondary else passphraseStrengthColor(score)
|
|
||||||
val announcement = stringResource(id = R.string.a11y_recovery_key_custom_strength_announcement, label)
|
|
||||||
.let { if (hint != null) "$it. $hint" else it }
|
|
||||||
Column(
|
|
||||||
modifier = modifier.semantics(mergeDescendants = true) {
|
|
||||||
contentDescription = announcement
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
color = color,
|
|
||||||
style = ElementTheme.typography.fontBodySmMedium,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { score },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = color,
|
|
||||||
)
|
|
||||||
if (hint != null) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = hint,
|
|
||||||
color = ElementTheme.colors.textSecondary,
|
|
||||||
style = ElementTheme.typography.fontBodySmRegular,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun CustomRecoveryPassphraseStrength.labelRes(): Int = when (this) {
|
|
||||||
CustomRecoveryPassphraseStrength.Garbage -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_garbage
|
|
||||||
CustomRecoveryPassphraseStrength.Weak -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_weak
|
|
||||||
CustomRecoveryPassphraseStrength.Moderate -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_moderate
|
|
||||||
CustomRecoveryPassphraseStrength.Okay -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_okay
|
|
||||||
CustomRecoveryPassphraseStrength.Strong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_strong
|
|
||||||
CustomRecoveryPassphraseStrength.VeryStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_very_strong
|
|
||||||
CustomRecoveryPassphraseStrength.UltraStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_ultra_strong
|
|
||||||
CustomRecoveryPassphraseStrength.Mega -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_mega
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun CustomRecoveryPassphraseStrength.hintRes(): Int = when (this) {
|
|
||||||
CustomRecoveryPassphraseStrength.Garbage -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_garbage
|
|
||||||
CustomRecoveryPassphraseStrength.Weak -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_weak
|
|
||||||
CustomRecoveryPassphraseStrength.Moderate -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_moderate
|
|
||||||
CustomRecoveryPassphraseStrength.Okay -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_okay
|
|
||||||
CustomRecoveryPassphraseStrength.Strong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_strong
|
|
||||||
CustomRecoveryPassphraseStrength.VeryStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_very_strong
|
|
||||||
CustomRecoveryPassphraseStrength.UltraStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_ultra_strong
|
|
||||||
CustomRecoveryPassphraseStrength.Mega -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_mega
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stops mirror iOS PasswordStrengthBar.gradient: Compound red900 / lime700 with the same vivid
|
|
||||||
// orange & yellow midpoints. Intentionally fixed (not light/dark semantic) so the gradient reads
|
|
||||||
// identically to iOS in both themes.
|
|
||||||
private val passphraseStrengthGradientStops = listOf(
|
|
||||||
0.25f to Color(0xFFD51928), // Compound red900
|
|
||||||
0.5f to Color(0xFFFF9500), // orange
|
|
||||||
0.75f to Color(0xFFFFCC00), // yellow
|
|
||||||
1.0f to Color(0xFF54C424), // Compound lime700
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Interpolates the strength bar colour along [passphraseStrengthGradientStops] for a 0f..1f score. */
|
|
||||||
private fun passphraseStrengthColor(score: Float): Color {
|
|
||||||
val clamped = score.coerceIn(0f, 1f)
|
|
||||||
var previousLocation = 0f
|
|
||||||
var previousColor = passphraseStrengthGradientStops.first().second
|
|
||||||
for ((location, color) in passphraseStrengthGradientStops) {
|
|
||||||
if (clamped <= location) {
|
|
||||||
val span = location - previousLocation
|
|
||||||
if (span <= 0f) return color
|
|
||||||
return lerp(previousColor, color, ((clamped - previousLocation) / span).coerceIn(0f, 1f))
|
|
||||||
}
|
|
||||||
previousLocation = location
|
|
||||||
previousColor = color
|
|
||||||
}
|
|
||||||
return previousColor
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CustomPassphraseConfirm(state: SecureBackupSetupState) {
|
|
||||||
var passphraseVisible by rememberSaveable { mutableStateOf(false) }
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 24.dp),
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag(TestTags.customRecoveryPassphraseConfirm)
|
|
||||||
.semantics { contentType = ContentType.NewPassword },
|
|
||||||
value = state.customPassphraseConfirm,
|
|
||||||
onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(it)) },
|
|
||||||
label = stringResource(id = CommonStrings.common_recovery_key),
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
|
||||||
trailingIcon = {
|
|
||||||
PasswordVisibilityToggle(
|
|
||||||
visible = passphraseVisible,
|
|
||||||
onToggle = { passphraseVisible = !passphraseVisible },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
if (state.canSubmitCustomPassphrase) {
|
|
||||||
state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
validity = if (state.customPassphraseMismatch) TextFieldValidity.Invalid else TextFieldValidity.None,
|
|
||||||
supportingText = if (state.customPassphraseMismatch) {
|
|
||||||
stringResource(id = R.string.pro_screen_recovery_key_mode_custom_mismatch)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@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.pro_screen_recovery_key_mode_confirm_finish_button),
|
|
||||||
enabled = state.canSubmitCustomPassphrase,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = { state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ColumnScope.AutoGenButtons(
|
|
||||||
state: SecureBackupSetupState,
|
|
||||||
onFinish: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val 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)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?><!--
|
|
||||||
~ Copyright (c) 2026 Element Creations Ltd.
|
|
||||||
~
|
|
||||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
~ Please see LICENSE files in the repository root for full details.
|
|
||||||
-->
|
|
||||||
<resources>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_title">Enter a recovery key</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_description">Choose a recovery key that you can memorize.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_confirm_title">Confirm your recovery key</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_confirm_description">Enter your recovery key again.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_field_footer">Minimum %1$s characters</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_confirm_finish_button">Finish setup</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_base">Strength</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_garbage">Garbage</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_weak">Weak</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_moderate">Moderate</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_okay">Okay</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_strong">Strong</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_very_strong">Very Strong</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_ultra_strong">Super Strong</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_mega">Ideal</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_garbage">Cracking would take roughly milliseconds.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_weak">Cracking would take roughly minutes to months.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_moderate">Cracking would take roughly years.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_okay">Cracking would take roughly hundreds of years.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_strong">Cracking would take roughly billions of years.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_very_strong">Cracking would take roughly trillions of years.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_ultra_strong">Cracking would take roughly quadrillions of years.</string>
|
|
||||||
<string name="pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_mega">Cracking would take roughly quintillions of years.</string>
|
|
||||||
<!-- Android-only strings: no iOS counterpart was provided, so keys are left as-is. -->
|
|
||||||
<string name="pro_screen_recovery_key_mode_custom_mismatch">The recovery key you entered doesn\'t match</string>
|
|
||||||
<string name="a11y_recovery_key_loading_specs">Loading recovery key requirements</string>
|
|
||||||
<string name="a11y_recovery_key_custom_strength_announcement">Passphrase strength: %1$s</string>
|
|
||||||
</resources>
|
|
||||||
+10
-746
@@ -6,37 +6,23 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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.features.wellknown.test.FakeSessionWellknownRetriever
|
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||||
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()
|
||||||
@@ -47,16 +33,11 @@ class SecureBackupSetupPresenterTest {
|
|||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val preFetch = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(preFetch.wellknownLoaded).isFalse()
|
assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse()
|
||||||
assertThat(preFetch.setupState).isEqualTo(SetupState.Init)
|
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
|
||||||
|
assertThat(initialState.showSaveConfirmationDialog).isFalse()
|
||||||
val loaded = awaitItem()
|
assertThat(initialState.recoveryKeyViewState).isEqualTo(
|
||||||
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,
|
||||||
@@ -69,17 +50,14 @@ 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 {
|
||||||
awaitItem() // pre-fetch
|
val initialState = awaitItem()
|
||||||
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)
|
||||||
@@ -91,6 +69,7 @@ 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(
|
||||||
@@ -121,9 +100,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.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(
|
||||||
@@ -149,7 +126,6 @@ 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)
|
||||||
@@ -176,7 +152,6 @@ 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()
|
||||||
@@ -211,727 +186,16 @@ class SecureBackupSetupPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - wellknownLoaded flips false to true exactly once after fetch resolves`() = runTest {
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val preFetch = awaitItem()
|
|
||||||
assertThat(preFetch.wellknownLoaded).isFalse()
|
|
||||||
assertThat(preFetch.customRecoveryPassphraseRequirements).isNull()
|
|
||||||
|
|
||||||
val loaded = awaitItem()
|
|
||||||
assertThat(loaded.wellknownLoaded).isTrue()
|
|
||||||
assertThat(loaded.customRecoveryPassphraseRequirements).isNotNull()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec absent leaves customRecoveryPassphraseRequirements null and uses generated-key path`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = null))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val preFetch = awaitItem()
|
|
||||||
assertThat(preFetch.wellknownLoaded).isFalse()
|
|
||||||
val loaded = awaitItem()
|
|
||||||
assertThat(loaded.wellknownLoaded).isTrue()
|
|
||||||
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
|
|
||||||
assertThat(loaded.canSubmitCustomPassphrase).isFalse()
|
|
||||||
loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
|
|
||||||
awaitItem() // creating
|
|
||||||
advanceUntilIdle()
|
|
||||||
enableRecoveryLambda.assertions().isCalledOnce()
|
|
||||||
.with(value(false), value(null))
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec present surfaces validation errors and blocks continue`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // initial, pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
assertThat(withSpec.customRecoveryPassphraseRequirements).isNotNull()
|
|
||||||
assertThat(withSpec.customEntryStep).isEqualTo(CustomEntryStep.Entry)
|
|
||||||
assertThat(withSpec.canContinueFromEntry).isFalse()
|
|
||||||
assertThat(withSpec.canSubmitCustomPassphrase).isFalse()
|
|
||||||
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc"))
|
|
||||||
val afterShortInput = awaitItem()
|
|
||||||
assertThat(afterShortInput.customPassphrase).isEqualTo("abc")
|
|
||||||
assertThat(afterShortInput.customPassphraseMeetsMinLength).isFalse()
|
|
||||||
assertThat(afterShortInput.canContinueFromEntry).isFalse()
|
|
||||||
|
|
||||||
// Continue is a no-op when entry is invalid: step stays Entry.
|
|
||||||
afterShortInput.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
expectNoEvents()
|
|
||||||
enableRecoveryLambda.assertions().isNeverCalled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec strength is null while empty and delegates to the enterprise estimator once user types`() = runTest {
|
|
||||||
// The estimation algorithm lives in the enterprise module; here we only verify the presenter
|
|
||||||
// suppresses the indicator while the field is empty and otherwise forwards the service result.
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
enterpriseService = FakeEnterpriseService(
|
|
||||||
isCustomRecoveryPassphraseEnabledResult = true,
|
|
||||||
estimateCustomRecoveryPassphraseStrengthResult = { passphrase ->
|
|
||||||
if (passphrase.length > 5) {
|
|
||||||
CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.9f)
|
|
||||||
} else {
|
|
||||||
CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Weak, score = 0.1f)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
// Field is empty → indicator suppressed (the estimator is never consulted).
|
|
||||||
assertThat(withSpec.customPassphraseStrength).isNull()
|
|
||||||
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc"))
|
|
||||||
val afterShort = awaitItem()
|
|
||||||
assertThat(afterShort.customPassphraseStrength?.strength)
|
|
||||||
.isEqualTo(CustomRecoveryPassphraseStrength.Weak)
|
|
||||||
|
|
||||||
afterShort.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("Abcdefg1!@#xyz"))
|
|
||||||
val afterStrong = awaitItem()
|
|
||||||
assertThat(afterStrong.customPassphraseStrength?.strength)
|
|
||||||
.isEqualTo(CustomRecoveryPassphraseStrength.Strong)
|
|
||||||
|
|
||||||
// Clearing the field brings the indicator back to null.
|
|
||||||
afterStrong.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(""))
|
|
||||||
val cleared = awaitItem()
|
|
||||||
assertThat(cleared.customPassphraseStrength).isNull()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec ContinueCustomPassphrase advances Entry to Confirm when valid`() = runTest {
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // initial
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
assertThat(withSpec.customEntryStep).isEqualTo(CustomEntryStep.Entry)
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
val typed = awaitItem()
|
|
||||||
assertThat(typed.canContinueFromEntry).isTrue()
|
|
||||||
|
|
||||||
typed.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val advanced = awaitItem()
|
|
||||||
assertThat(advanced.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
|
|
||||||
assertThat(advanced.customPassphrase).isEqualTo(valid)
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec BackToCustomEntry returns to Entry preserving both fields`() = runTest {
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // initial
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
awaitItem()
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
awaitItem()
|
|
||||||
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.BackToCustomEntry)
|
|
||||||
val backOnEntry = awaitItem()
|
|
||||||
assertThat(backOnEntry.customEntryStep).isEqualTo(CustomEntryStep.Entry)
|
|
||||||
assertThat(backOnEntry.customPassphrase).isEqualTo(valid)
|
|
||||||
assertThat(backOnEntry.customPassphraseConfirm).isEqualTo(valid)
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec SubmitCustomPassphrase from Entry step is a no-op`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // initial
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
val typed = awaitItem()
|
|
||||||
// Both fields contain matching content via a stale path: simulate by also setting confirm
|
|
||||||
// while still on the Entry step (e.g., previously typed). Submit must still be gated.
|
|
||||||
typed.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val bothTyped = awaitItem()
|
|
||||||
assertThat(bothTyped.customEntryStep).isEqualTo(CustomEntryStep.Entry)
|
|
||||||
assertThat(bothTyped.canSubmitCustomPassphrase).isFalse()
|
|
||||||
|
|
||||||
bothTyped.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
expectNoEvents()
|
|
||||||
enableRecoveryLambda.assertions().isNeverCalled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec present submit with valid input forwards passphrase to SDK`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // initial
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
val afterKey = awaitItem()
|
|
||||||
afterKey.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val ready = awaitItem()
|
|
||||||
assertThat(ready.canSubmitCustomPassphrase).isTrue()
|
|
||||||
|
|
||||||
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
awaitItem() // creating
|
|
||||||
advanceUntilIdle()
|
|
||||||
enableRecoveryLambda.assertions().isCalledOnce()
|
|
||||||
.with(value(false), value(valid))
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec error retains typed passphrase and Confirm step across DismissDialog`() = runTest {
|
|
||||||
val encryptionService = FakeEncryptionService(
|
|
||||||
enableRecoveryLambda = { _, _ -> Result.failure(IllegalStateException("boom")) },
|
|
||||||
)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
awaitItem()
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val ready = awaitItem()
|
|
||||||
assertThat(ready.canSubmitCustomPassphrase).isTrue()
|
|
||||||
|
|
||||||
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
awaitItem() // creating
|
|
||||||
val errored = awaitItem()
|
|
||||||
assertThat(errored.setupState).isInstanceOf(SetupState.Error::class.java)
|
|
||||||
|
|
||||||
errored.eventSink.invoke(SecureBackupSetupEvents.DismissDialog)
|
|
||||||
val afterDismiss = awaitItem()
|
|
||||||
assertThat(afterDismiss.setupState).isEqualTo(SetupState.Init)
|
|
||||||
assertThat(afterDismiss.customPassphrase).isEqualTo(valid)
|
|
||||||
assertThat(afterDismiss.customPassphraseConfirm).isEqualTo(valid)
|
|
||||||
assertThat(afterDismiss.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
|
|
||||||
assertThat(afterDismiss.canSubmitCustomPassphrase).isTrue()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec submit success auto-advances to CreatedAndSaved without manual save`() = runTest {
|
|
||||||
val encryptionService = FakeEncryptionService(
|
|
||||||
enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) },
|
|
||||||
)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
awaitItem()
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val ready = awaitItem()
|
|
||||||
assertThat(ready.canSubmitCustomPassphrase).isTrue()
|
|
||||||
|
|
||||||
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
awaitItem() // creating
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// The presenter's auto-skip LaunchedEffect should have advanced state machine past
|
|
||||||
// Created without requiring a manual RecoveryKeyHasBeenSaved event. The terminal
|
|
||||||
// state observed is CreatedAndSaved.
|
|
||||||
val finalState = expectMostRecentItem()
|
|
||||||
assertThat(finalState.setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
|
|
||||||
// The SDK-derived base58 key must not leak through the recoveryKeyViewState in
|
|
||||||
// the custom-passphrase flow.
|
|
||||||
assertThat(finalState.recoveryKeyViewState.formattedRecoveryKey).isNull()
|
|
||||||
// The state machine's KeyCreatedAndSaved variant carries a key too — confirm
|
|
||||||
// the presenter scrubbed it before dispatching SdkHasCreatedKey, so observers
|
|
||||||
// of setupState don't see the SDK-generated base58 key either.
|
|
||||||
assertThat(finalState.setupState).isEqualTo(SetupState.CreatedAndSaved(formattedRecoveryKey = ""))
|
|
||||||
assertThat(finalState.setupState.recoveryKey()).isEqualTo("")
|
|
||||||
// Typed passphrase is cleared once the SDK call returns successfully.
|
|
||||||
assertThat(finalState.customPassphrase).isEmpty()
|
|
||||||
assertThat(finalState.customPassphraseConfirm).isEmpty()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - enterprise feature disabled suppresses spec and forces auto-gen path`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
enterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = false),
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val loaded = awaitItem()
|
|
||||||
assertThat(loaded.wellknownLoaded).isTrue()
|
|
||||||
// Even though the homeserver advertised a spec, the enterprise gate suppresses it.
|
|
||||||
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
|
|
||||||
assertThat(loaded.canSubmitCustomPassphrase).isFalse()
|
|
||||||
loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
|
|
||||||
awaitItem() // creating
|
|
||||||
advanceUntilIdle()
|
|
||||||
enableRecoveryLambda.assertions().isCalledOnce()
|
|
||||||
.with(value(false), value(null))
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - well-known fetch failure falls back to generated-key path`() = runTest {
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Error(IllegalStateException("boom"))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val preFetch = awaitItem()
|
|
||||||
assertThat(preFetch.wellknownLoaded).isFalse()
|
|
||||||
val loaded = awaitItem()
|
|
||||||
assertThat(loaded.wellknownLoaded).isTrue()
|
|
||||||
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - change + custom spec absent uses resetRecoveryKey path`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
isChangeRecoveryKeyUserStory = true,
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = null))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val loaded = awaitItem()
|
|
||||||
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
|
|
||||||
loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
|
|
||||||
awaitItem() // creating
|
|
||||||
val createdState = awaitItem()
|
|
||||||
assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY))
|
|
||||||
enableRecoveryLambda.assertions().isNeverCalled()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - change + custom spec present surfaces validation errors and blocks continue`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
isChangeRecoveryKeyUserStory = true,
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // initial, pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
assertThat(withSpec.customRecoveryPassphraseRequirements).isNotNull()
|
|
||||||
assertThat(withSpec.canContinueFromEntry).isFalse()
|
|
||||||
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc"))
|
|
||||||
val afterShortInput = awaitItem()
|
|
||||||
assertThat(afterShortInput.customPassphrase).isEqualTo("abc")
|
|
||||||
assertThat(afterShortInput.customPassphraseMeetsMinLength).isFalse()
|
|
||||||
assertThat(afterShortInput.canContinueFromEntry).isFalse()
|
|
||||||
|
|
||||||
afterShortInput.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
expectNoEvents()
|
|
||||||
enableRecoveryLambda.assertions().isNeverCalled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - change + custom spec present submit with valid input forwards passphrase to enableRecovery`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
isChangeRecoveryKeyUserStory = true,
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // initial
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
val afterKey = awaitItem()
|
|
||||||
afterKey.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val ready = awaitItem()
|
|
||||||
assertThat(ready.canSubmitCustomPassphrase).isTrue()
|
|
||||||
|
|
||||||
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
awaitItem() // creating
|
|
||||||
advanceUntilIdle()
|
|
||||||
enableRecoveryLambda.assertions().isCalledOnce()
|
|
||||||
.with(value(false), value(valid))
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - change + well-known fetch failure falls back to resetRecoveryKey path`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
isChangeRecoveryKeyUserStory = true,
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Error(IllegalStateException("boom"))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val loaded = awaitItem()
|
|
||||||
assertThat(loaded.customRecoveryPassphraseRequirements).isNull()
|
|
||||||
loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
|
|
||||||
awaitItem() // creating
|
|
||||||
val createdState = awaitItem()
|
|
||||||
assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY))
|
|
||||||
enableRecoveryLambda.assertions().isNeverCalled()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec Confirm mismatch clears once user edits to match`() = runTest {
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
awaitItem()
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
assertThat(onConfirm.customPassphraseMismatch).isFalse()
|
|
||||||
|
|
||||||
// Typing a different value flips mismatch=true and blocks submit.
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm("differen"))
|
|
||||||
val mismatched = awaitItem()
|
|
||||||
assertThat(mismatched.customPassphraseMismatch).isTrue()
|
|
||||||
assertThat(mismatched.canSubmitCustomPassphrase).isFalse()
|
|
||||||
|
|
||||||
// Editing the confirm field to match clears the mismatch and re-enables submit.
|
|
||||||
mismatched.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val matched = awaitItem()
|
|
||||||
assertThat(matched.customPassphraseMismatch).isFalse()
|
|
||||||
assertThat(matched.canSubmitCustomPassphrase).isTrue()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec rapid double-submit invokes the SDK only once`() = runTest {
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
awaitItem()
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val ready = awaitItem()
|
|
||||||
assertThat(ready.canSubmitCustomPassphrase).isTrue()
|
|
||||||
|
|
||||||
// Two synchronous submits dispatched from the same snapshot (e.g., button + IME-Done
|
|
||||||
// racing). The presenter's in-flight guard must drop the second one before it
|
|
||||||
// launches a second enableRecovery coroutine.
|
|
||||||
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
advanceUntilIdle()
|
|
||||||
enableRecoveryLambda.assertions().isCalledOnce()
|
|
||||||
.with(value(false), value(valid))
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - change + custom spec submit routes to enableRecovery and never resetRecoveryKey`() = runTest {
|
|
||||||
// Guards the custom-passphrase Change path against silently dropping the passphrase:
|
|
||||||
// the user's passphrase reaches the SDK only via enableRecovery(passphrase); the
|
|
||||||
// passphrase-less resetRecoveryKey() path must never be taken here. A true end-to-end
|
|
||||||
// check that the 4S key actually rotates needs the real SDK + homeserver and lives
|
|
||||||
// outside this JVM suite.
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val resetRecoveryKeyLambda = lambdaRecorder<Result<String>> { Result.success(FakeEncryptionService.FAKE_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(
|
|
||||||
enableRecoveryLambda = enableRecoveryLambda,
|
|
||||||
resetRecoveryKeyLambda = resetRecoveryKeyLambda,
|
|
||||||
)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
isChangeRecoveryKeyUserStory = true,
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
awaitItem()
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val ready = awaitItem()
|
|
||||||
assertThat(ready.canSubmitCustomPassphrase).isTrue()
|
|
||||||
|
|
||||||
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
awaitItem() // creating
|
|
||||||
advanceUntilIdle()
|
|
||||||
enableRecoveryLambda.assertions().isCalledOnce()
|
|
||||||
.with(value(false), value(valid))
|
|
||||||
resetRecoveryKeyLambda.assertions().isNeverCalled()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec cancel while creating aborts the SDK call and returns to Confirm with input preserved`() = runTest {
|
|
||||||
// simulateLongTask suspends on delay(1) before invoking the lambda, so cancelling before
|
|
||||||
// advancing virtual time guarantees the SDK call never runs.
|
|
||||||
val enableRecoveryLambda = lambdaRecorder<Boolean, String?, Result<String>> { _, _ -> Result.success(A_RECOVERY_KEY) }
|
|
||||||
val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda)
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
encryptionService = encryptionService,
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
awaitItem()
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid))
|
|
||||||
val ready = awaitItem()
|
|
||||||
assertThat(ready.canSubmitCustomPassphrase).isTrue()
|
|
||||||
|
|
||||||
ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
|
|
||||||
val creating = awaitItem()
|
|
||||||
assertThat(creating.setupState).isEqualTo(SetupState.Creating)
|
|
||||||
|
|
||||||
creating.eventSink.invoke(SecureBackupSetupEvents.CancelCustomPassphraseSubmit)
|
|
||||||
val cancelled = awaitItem()
|
|
||||||
assertThat(cancelled.setupState).isEqualTo(SetupState.Init)
|
|
||||||
assertThat(cancelled.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
|
|
||||||
assertThat(cancelled.customPassphrase).isEqualTo(valid)
|
|
||||||
assertThat(cancelled.customPassphraseConfirm).isEqualTo(valid)
|
|
||||||
assertThat(cancelled.canSubmitCustomPassphrase).isTrue()
|
|
||||||
|
|
||||||
// The cancelled coroutine never reached the SDK, and no zombie success/error followed.
|
|
||||||
advanceUntilIdle()
|
|
||||||
enableRecoveryLambda.assertions().isNeverCalled()
|
|
||||||
expectNoEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `present - custom spec strength indicator is suppressed on the Confirm step`() = runTest {
|
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
|
||||||
sessionWellknownRetriever = FakeSessionWellknownRetriever {
|
|
||||||
WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements()))
|
|
||||||
},
|
|
||||||
enterpriseService = FakeEnterpriseService(
|
|
||||||
isCustomRecoveryPassphraseEnabledResult = true,
|
|
||||||
estimateCustomRecoveryPassphraseStrengthResult = {
|
|
||||||
CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.9f)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem() // pre-fetch
|
|
||||||
val withSpec = awaitItem()
|
|
||||||
|
|
||||||
val valid = "Ab12!@cd"
|
|
||||||
withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid))
|
|
||||||
val onEntry = awaitItem()
|
|
||||||
// Entry step: the estimator result is surfaced.
|
|
||||||
assertThat(onEntry.customPassphraseStrength?.strength).isEqualTo(CustomRecoveryPassphraseStrength.Strong)
|
|
||||||
|
|
||||||
onEntry.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
|
|
||||||
val onConfirm = awaitItem()
|
|
||||||
// Confirm step: indicator is not rendered, so the presenter stops computing it.
|
|
||||||
assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm)
|
|
||||||
assertThat(onConfirm.customPassphraseStrength).isNull()
|
|
||||||
cancelAndIgnoreRemainingEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createSecureBackupSetupPresenter(
|
private fun createSecureBackupSetupPresenter(
|
||||||
isChangeRecoveryKeyUserStory: Boolean = false,
|
isChangeRecoveryKeyUserStory: Boolean = false,
|
||||||
encryptionService: EncryptionService = FakeEncryptionService(
|
encryptionService: EncryptionService = FakeEncryptionService(
|
||||||
enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) },
|
enableRecoveryLambda = { _, _ -> Result.success("") },
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user