Add custom recovery passphrase setup flow

When the homeserver advertises custom_recovery_passphrase_settings (and
the enterprise gate is enabled), the secure-backup setup lets the user
enter and confirm their own recovery key with a live strength indicator,
instead of receiving a generated one. The SDK derives the 4S key from the
passphrase and the base58 key is scrubbed everywhere so it is never
shown. Falls back to the generated-key flow when no spec is present or
the well-known fetch fails.

Snapshot PNGs are intentionally left out; the core team regenerates them
after the PR is opened.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-06-02 13:52:21 -07:00
parent 0cb9d2911d
commit db6f4414df
11 changed files with 1552 additions and 71 deletions
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.securebackup.impl.setup
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
/**
* Validation flags computed once and shared by the presenter and the preview provider.
*
* Note: passphrase strength is intentionally NOT derived here. The estimation algorithm is an
* enterprise capability ([io.element.android.features.enterprise.api.EnterpriseService.estimateCustomRecoveryPassphraseStrength]);
* the presenter computes it via the injected service and the state provider supplies samples directly.
*/
internal data class CustomPassphraseDerivations(
val meetsMinLength: Boolean,
val mismatch: Boolean,
val canContinueFromEntry: Boolean,
val canSubmitCustomPassphrase: Boolean,
)
internal fun deriveCustomPassphraseState(
requirements: CustomRecoveryPassphraseRequirements?,
passphrase: String,
confirm: String,
step: CustomEntryStep,
setupState: SetupState,
): CustomPassphraseDerivations {
val meetsMinLength = requirements?.isSatisfiedBy(passphrase) ?: true
val mismatch = confirm.isNotEmpty() && passphrase != confirm
val canContinueFromEntry = requirements != null &&
passphrase.isNotEmpty() &&
meetsMinLength &&
setupState is SetupState.Init
val canSubmit = requirements != null &&
passphrase.isNotEmpty() &&
passphrase == confirm &&
meetsMinLength &&
step == CustomEntryStep.Confirm &&
setupState is SetupState.Init
return CustomPassphraseDerivations(
meetsMinLength = meetsMinLength,
mismatch = mismatch,
canContinueFromEntry = canContinueFromEntry,
canSubmitCustomPassphrase = canSubmit,
)
}
@@ -13,4 +13,22 @@ sealed interface SecureBackupSetupEvents {
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents
data object Done : SecureBackupSetupEvents
data object DismissDialog : SecureBackupSetupEvents
/** Update the user-typed custom recovery passphrase. */
data class UpdateCustomPassphrase(val value: String) : SecureBackupSetupEvents
/** Update the user-typed confirmation field. */
data class UpdateCustomPassphraseConfirm(val value: String) : SecureBackupSetupEvents
/** Advance from the Entry step to the Confirm step. No-op unless the entry passphrase meets requirements. */
data object ContinueCustomPassphrase : SecureBackupSetupEvents
/** Step back from Confirm to Entry. Both typed values are preserved. */
data object BackToCustomEntry : SecureBackupSetupEvents
/** Submit the custom passphrase to the SDK (only valid when [SecureBackupSetupState.canSubmitCustomPassphrase]). */
data object SubmitCustomPassphrase : SecureBackupSetupEvents
/** Abort an in-flight custom submit (back press while creating) and return to the Confirm step. */
data object CancelCustomPassphraseSubmit : SecureBackupSetupEvents
}
@@ -11,6 +11,7 @@
package io.element.android.features.securebackup.impl.setup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -22,22 +23,33 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.securebackup.impl.loggerTagSetup
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
private data class WellknownStatus(
val loaded: Boolean,
val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?,
)
@AssistedInject
class SecureBackupSetupPresenter(
@Assisted private val isChangeRecoveryKeyUserStory: Boolean,
private val stateMachine: SecureBackupSetupStateMachine,
private val encryptionService: EncryptionService,
private val sessionWellknownRetriever: SessionWellknownRetriever,
private val enterpriseService: EnterpriseService,
) : Presenter<SecureBackupSetupState> {
@AssistedFactory
interface Factory {
@@ -53,10 +65,81 @@ class SecureBackupSetupPresenter(
}
var showSaveConfirmationDialog by remember { mutableStateOf(false) }
// Spinner until the well-known fetch settles, so the user can't start the auto-gen path
// while a custom spec is still in flight. The loaded/specs pair flips in one assignment.
val wellknownStatusState = remember {
mutableStateOf(WellknownStatus(loaded = false, customRecoveryPassphraseRequirements = null))
}
val wellknownLoaded by remember {
derivedStateOf { wellknownStatusState.value.loaded }
}
val customRecoveryPassphraseRequirements by remember {
derivedStateOf { wellknownStatusState.value.customRecoveryPassphraseRequirements }
}
// Not rememberSaveable: the passphrase must never reach the on-disk saved-state bundle.
// Plain String (not a zeroed CharArray): the TextField/event/SDK boundary all copy Strings
// anyway, so we just clear it on success and accept loss on process death.
var customPassphrase by remember { mutableStateOf("") }
var customPassphraseConfirm by remember { mutableStateOf("") }
var customEntryStep by remember { mutableStateOf(CustomEntryStep.Entry) }
// Single-flight guard: a button tap and an IME-Done in the same frame both see
// canSubmit=true and would each launch an enableRecovery coroutine. The state machine
// dedupes UserCreatesKey, but only this flag stops the duplicate SDK call.
var customSubmitInFlight by remember { mutableStateOf(false) }
// Handle to the in-flight custom submit so a back press can abort it (see CancelCustomPassphraseSubmit).
var customSubmitJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(setupState) {
if (setupState !is SetupState.Creating) {
customSubmitInFlight = false
}
}
LaunchedEffect(Unit) {
val result = sessionWellknownRetriever.getElementWellKnown()
// Enterprise gate: even if the homeserver advertises a custom spec, only honor it on
// builds where the feature is enabled. FOSS builds always fall back to the auto-gen path.
val specsFromWellknown = (result as? WellknownRetrieverResult.Success)?.data?.customRecoveryPassphraseRequirements
wellknownStatusState.value = WellknownStatus(
loaded = true,
customRecoveryPassphraseRequirements = specsFromWellknown.takeIf { enterpriseService.isCustomRecoveryPassphraseEnabled() },
)
}
// Shared with SecureBackupSetupStateProvider so previews never drift from runtime.
val derivations by remember {
derivedStateOf {
deriveCustomPassphraseState(
requirements = wellknownStatusState.value.customRecoveryPassphraseRequirements,
passphrase = customPassphrase,
confirm = customPassphraseConfirm,
step = customEntryStep,
setupState = setupState,
)
}
}
// Strength is an enterprise-only estimation; null while the field is empty or in FOSS builds.
// Only computed on the Entry step, the only place the indicator is rendered.
val customPassphraseStrength by remember {
derivedStateOf {
customPassphrase
.takeIf { customEntryStep == CustomEntryStep.Entry && it.isNotEmpty() }
?.let { enterpriseService.estimateCustomRecoveryPassphraseStrength(it) }
}
}
// Nothing to "save" when the user chose the passphrase: auto-skip the Created step.
LaunchedEffect(setupState, customRecoveryPassphraseRequirements) {
if (customRecoveryPassphraseRequirements != null && setupState is SetupState.Created) {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
}
}
fun handleEvent(event: SecureBackupSetupEvents) {
when (event) {
SecureBackupSetupEvents.CreateRecoveryKey -> {
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch)
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch, passphrase = null)
}
SecureBackupSetupEvents.RecoveryKeyHasBeenSaved ->
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
@@ -67,12 +150,51 @@ class SecureBackupSetupPresenter(
SecureBackupSetupEvents.Done -> {
showSaveConfirmationDialog = true
}
is SecureBackupSetupEvents.UpdateCustomPassphrase -> {
customPassphrase = event.value
}
is SecureBackupSetupEvents.UpdateCustomPassphraseConfirm -> {
customPassphraseConfirm = event.value
}
SecureBackupSetupEvents.ContinueCustomPassphrase -> {
if (derivations.canContinueFromEntry) {
customEntryStep = CustomEntryStep.Confirm
}
}
SecureBackupSetupEvents.BackToCustomEntry -> {
customEntryStep = CustomEntryStep.Entry
}
SecureBackupSetupEvents.SubmitCustomPassphrase -> {
if (derivations.canSubmitCustomPassphrase && !customSubmitInFlight) {
customSubmitInFlight = true
customSubmitJob = coroutineScope.createOrChangeRecoveryKey(
stateAndDispatch,
passphrase = customPassphrase,
onSuccess = {
customPassphrase = ""
customPassphraseConfirm = ""
},
)
}
}
SecureBackupSetupEvents.CancelCustomPassphraseSubmit -> {
// Abort the SDK call and snap back to Initial. Dispatch the reset first so that
// if the (now cancelled) coroutine still manages to dispatch SdkError/
// SdkHasCreatedKey, those land in Initial where the state machine ignores them.
customSubmitJob?.cancel()
customSubmitJob = null
customSubmitInFlight = false
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCancelledCreate)
}
}
}
val isCustomFlow = customRecoveryPassphraseRequirements != null
val recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
formattedRecoveryKey = setupState.recoveryKey(),
// Custom flow: never surface the SDK base58 key in view state, even during the
// brief Created/CreatedAndSaved auto-skip window.
formattedRecoveryKey = if (isCustomFlow) null else setupState.recoveryKey(),
displayTextFieldContents = true,
inProgress = setupState is SetupState.Creating,
)
@@ -82,6 +204,16 @@ class SecureBackupSetupPresenter(
recoveryKeyViewState = recoveryKeyViewState,
setupState = setupState,
showSaveConfirmationDialog = showSaveConfirmationDialog,
wellknownLoaded = wellknownLoaded,
customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements,
customEntryStep = customEntryStep,
customPassphrase = customPassphrase,
customPassphraseConfirm = customPassphraseConfirm,
customPassphraseMeetsMinLength = derivations.meetsMinLength,
customPassphraseMismatch = derivations.mismatch,
customPassphraseStrength = customPassphraseStrength,
canContinueFromEntry = derivations.canContinueFromEntry,
canSubmitCustomPassphrase = derivations.canSubmitCustomPassphrase,
eventSink = ::handleEvent,
)
}
@@ -98,47 +230,43 @@ class SecureBackupSetupPresenter(
}
private fun CoroutineScope.createOrChangeRecoveryKey(
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>,
passphrase: String?,
onSuccess: () -> Unit = {},
) = launch {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey)
if (isChangeRecoveryKeyUserStory) {
// Custom passphrase → enableRecovery(passphrase): the SDK derives the 4S key from it.
// For the Change flow this rotates in place — the SDK skips backup creation when recovery
// is already enabled (confirmed), so there's no key-backup teardown or room-key re-upload.
val result = if (passphrase != null) {
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=present)")
encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = passphrase)
} else if (isChangeRecoveryKeyUserStory) {
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()")
encryptionService.resetRecoveryKey().fold(
onSuccess = {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it))
},
onFailure = {
if (it is Exception) {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
}
}
)
encryptionService.resetRecoveryKey()
} else {
observeEncryptionService(stateAndDispatch)
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()")
encryptionService.enableRecovery(waitForBackupsToUpload = false).onFailure {
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=absent)")
encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = null)
}
result.fold(
onSuccess = { key ->
// Clear buffers only on success; on failure keep them so the user can retry
// without retyping.
onSuccess()
// Custom flow: scrub the SDK base58 key from the state machine so SetupState
// never carries it. RustEncryptionService likewise keeps it out of
// enableRecoveryProgressStateFlow for the passphrase path, so it is not retained
// anywhere once this local goes out of scope.
val storedKey = if (passphrase != null) "" else key
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(storedKey))
},
onFailure = {
Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery")
if (it is Exception) {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
}
// The state machine only accepts Exception; wrap anything else so a failure
// still leaves the Creating spinner instead of hanging on it.
val exception = it as? Exception ?: RuntimeException(it)
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(exception))
}
}
}
private fun CoroutineScope.observeEncryptionService(
stateAndDispatch: StateAndDispatch<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))
}
}
)
}
}
@@ -8,16 +8,40 @@
package io.element.android.features.securebackup.impl.setup
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
data class SecureBackupSetupState(
val isChangeRecoveryKeyUserStory: Boolean,
val recoveryKeyViewState: RecoveryKeyViewState,
val showSaveConfirmationDialog: Boolean,
val setupState: SetupState,
/** False while the well-known is still being fetched; the view shows a spinner until true. */
val wellknownLoaded: Boolean,
/** Non-null when the well-known requires a user-chosen passphrase; hides the generated-key UI. */
val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?,
val customEntryStep: CustomEntryStep,
/** User-typed passphrase. Not persisted to saved-state; cleared after a successful submit. */
val customPassphrase: String,
/** Confirmation field; same lifetime contract as [customPassphrase]. */
val customPassphraseConfirm: String,
/** True when [customPassphrase] satisfies the minimum-character-count rule from [customRecoveryPassphraseRequirements]. */
val customPassphraseMeetsMinLength: Boolean,
val customPassphraseMismatch: Boolean,
/** Null while the Entry-step passphrase is empty; otherwise the latest strength reading rendered below the field. */
val customPassphraseStrength: CustomRecoveryPassphraseStrengthResult?,
/** True when the Entry-step passphrase is valid enough to advance to the Confirm step. */
val canContinueFromEntry: Boolean,
val canSubmitCustomPassphrase: Boolean,
val eventSink: (SecureBackupSetupEvents) -> Unit
)
enum class CustomEntryStep {
Entry,
Confirm,
}
sealed interface SetupState {
data object Init : SetupState
data object Creating : SetupState
@@ -34,6 +34,9 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine<SecureBackupSetupSta
on { event: Event.SdkHasCreatedKey, state: MachineState<State.CreatingKey> ->
state.override { State.KeyCreated(event.key) }
}
on { _: Event.UserCancelledCreate, state: MachineState<State.CreatingKey> ->
state.override { State.Initial }
}
}
inState<State.KeyCreated> {
on { _: Event.UserSavedKey, state: MachineState<State.KeyCreated> ->
@@ -64,5 +67,8 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine<SecureBackupSetupSta
data class SdkError(val exception: Exception) : Event
data object UserSavedKey : Event
data object ClearError : Event
/** User backed out while the SDK call was still in flight; abort and return to Initial. */
data object UserCancelledCreate : Event
}
}
@@ -9,13 +9,17 @@
package io.element.android.features.securebackup.impl.setup
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrength
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements
open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBackupSetupState> {
override val values: Sequence<SecureBackupSetupState>
get() = sequenceOf(
aSecureBackupSetupState(wellknownLoaded = false),
aSecureBackupSetupState(setupState = SetupState.Init),
aSecureBackupSetupState(setupState = SetupState.Creating),
aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())),
@@ -25,25 +29,141 @@ open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBacku
showSaveConfirmationDialog = true,
),
aSecureBackupSetupState(setupState = SetupState.Error(Exception("Test error"))),
// Add other states here
// Custom-entry: empty Entry step
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "",
),
// Custom-entry: Entry step too-short input
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "abc",
),
// Custom-entry: Entry step with a Garbage-tier passphrase (zero score, neutral colour)
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "abcdefgh",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Garbage, score = 0f),
),
// Custom-entry: Entry step with a Weak-tier passphrase
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "abcde1",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Weak, score = 0.15f),
),
// Custom-entry: Entry step with a Moderate-tier passphrase
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "Abcdefg1",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Moderate, score = 0.45f),
),
// Custom-entry: Entry step with a Strong-tier passphrase ready to continue
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "Abcdefg1!@#x",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.7f),
),
// Custom-entry: Entry step with a top-tier (Ideal) passphrase
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Entry,
customPassphrase = "Abcdefg1!@#xyz",
customPassphraseStrength = CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Mega, score = 1f),
),
// Custom-entry: Confirm step empty
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
customPassphrase = "abcdefgh",
customPassphraseConfirm = "",
),
// Custom-entry: Confirm step mismatch
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
customPassphrase = "abcdefgh",
customPassphraseConfirm = "different",
),
// Custom-entry: Confirm step ready to submit
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
customPassphrase = "abcdefgh",
customPassphraseConfirm = "abcdefgh",
),
// Custom-entry: submitting (spinner instead of base58 share UI)
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
setupState = SetupState.Creating,
),
// Custom-entry: SDK error overlay
aSecureBackupSetupState(
customRecoveryPassphraseRequirements = aPreviewCustomRecoveryPassphraseRequirements(),
customEntryStep = CustomEntryStep.Confirm,
setupState = SetupState.Error(Exception("Test error")),
),
)
}
fun aSecureBackupSetupState(
isChangeRecoveryKeyUserStory: Boolean = false,
setupState: SetupState = SetupState.Init,
showSaveConfirmationDialog: Boolean = false,
) = SecureBackupSetupState(
isChangeRecoveryKeyUserStory = false,
setupState = setupState,
showSaveConfirmationDialog = showSaveConfirmationDialog,
recoveryKeyViewState = setupState.toRecoveryKeyViewState(),
eventSink = {}
wellknownLoaded: Boolean = true,
customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements? = null,
customEntryStep: CustomEntryStep = CustomEntryStep.Entry,
customPassphrase: String = "",
customPassphraseConfirm: String = "",
customPassphraseStrength: CustomRecoveryPassphraseStrengthResult? = null,
): SecureBackupSetupState {
val derivations = deriveCustomPassphraseState(
requirements = customRecoveryPassphraseRequirements,
passphrase = customPassphrase,
confirm = customPassphraseConfirm,
step = customEntryStep,
setupState = setupState,
)
return SecureBackupSetupState(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
setupState = setupState,
showSaveConfirmationDialog = showSaveConfirmationDialog,
recoveryKeyViewState = setupState.toRecoveryKeyViewState(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
suppressKey = customRecoveryPassphraseRequirements != null,
),
wellknownLoaded = wellknownLoaded,
customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements,
customEntryStep = customEntryStep,
customPassphrase = customPassphrase,
customPassphraseConfirm = customPassphraseConfirm,
customPassphraseMeetsMinLength = derivations.meetsMinLength,
customPassphraseMismatch = derivations.mismatch,
customPassphraseStrength = customPassphraseStrength,
canContinueFromEntry = derivations.canContinueFromEntry,
canSubmitCustomPassphrase = derivations.canSubmitCustomPassphrase,
eventSink = {}
)
}
private fun aPreviewCustomRecoveryPassphraseRequirements() = CustomRecoveryPassphraseRequirements(
minCharacterCount = 8,
)
private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState {
private fun SetupState.toRecoveryKeyViewState(
isChangeRecoveryKeyUserStory: Boolean,
suppressKey: Boolean,
): RecoveryKeyViewState {
return RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = recoveryKey(),
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
// Match the presenter — the custom-passphrase flow never surfaces the SDK base58 key.
formattedRecoveryKey = if (suppressKey) null else recoveryKey(),
displayTextFieldContents = true,
inProgress = this is SetupState.Creating,
)
@@ -8,16 +8,43 @@
package io.element.android.features.securebackup.impl.setup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrength
import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
import io.element.android.libraries.androidutils.system.copyToClipboard
@@ -29,8 +56,17 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.PasswordVisibilityToggle
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -40,13 +76,19 @@ fun SecureBackupSetupView(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Custom flow auto-skips the "save your key" screen: finish once the SDK accepted it.
LaunchedEffect(state.setupState, state.isCustomEntry()) {
if (state.isCustomEntry() && state.setupState is SetupState.CreatedAndSaved) {
onSuccess()
}
}
FlowStepPage(
modifier = modifier,
onBackClick = onBackClick.takeIf { state.canGoBack() },
onBackClick = backClickHandler(state, onBackClick),
title = title(state),
subTitle = subtitle(state),
iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()),
buttons = { Buttons(state, onFinish = onSuccess) },
buttons = { Buttons(state, onFinish = onSuccess, onCancel = onBackClick) },
) {
Content(state = state)
}
@@ -78,15 +120,45 @@ private fun SecureBackupSetupState.canGoBack(): Boolean {
return recoveryKeyViewState.formattedRecoveryKey == null
}
private fun SecureBackupSetupState.isCustomEntry(): Boolean =
customRecoveryPassphraseRequirements != null
private fun backClickHandler(
state: SecureBackupSetupState,
onBackClick: () -> Unit,
): (() -> Unit)? {
if (!state.canGoBack()) return null
if (state.isCustomEntry()) {
// Back while the SDK call is in flight aborts it and returns to the Confirm step
// (typed input preserved) rather than silently flipping the step under the spinner.
if (state.setupState is SetupState.Creating) {
return { state.eventSink.invoke(SecureBackupSetupEvents.CancelCustomPassphraseSubmit) }
}
if (state.customEntryStep == CustomEntryStep.Confirm) {
// In the custom flow, backing out of Confirm returns to Entry (preserve typed input).
return { state.eventSink.invoke(SecureBackupSetupEvents.BackToCustomEntry) }
}
}
return onBackClick
}
@Composable
private fun title(state: SecureBackupSetupState): String {
// Hide the heading until the well-known resolves, so it can't flip copy under the user.
if (!state.wellknownLoaded) return ""
// Custom flow has no "save your key" step — use per-step custom titles throughout.
if (state.isCustomEntry()) {
return when (state.customEntryStep) {
CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_title)
CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_title)
}
}
return when (state.setupState) {
SetupState.Init,
SetupState.Creating,
is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) {
stringResource(id = R.string.screen_recovery_key_change_title)
} else {
stringResource(id = R.string.screen_recovery_key_setup_title)
is SetupState.Error -> when {
state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_title)
else -> stringResource(id = R.string.screen_recovery_key_setup_title)
}
is SetupState.Created,
is SetupState.CreatedAndSaved ->
@@ -95,14 +167,20 @@ private fun title(state: SecureBackupSetupState): String {
}
@Composable
private fun subtitle(state: SecureBackupSetupState): String {
private fun subtitle(state: SecureBackupSetupState): String? {
if (!state.wellknownLoaded) return null
if (state.isCustomEntry()) {
return when (state.customEntryStep) {
CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_description)
CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_description)
}
}
return when (state.setupState) {
SetupState.Init,
SetupState.Creating,
is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) {
stringResource(id = R.string.screen_recovery_key_change_description)
} else {
stringResource(id = R.string.screen_recovery_key_setup_description)
is SetupState.Error -> when {
state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_description)
else -> stringResource(id = R.string.screen_recovery_key_setup_description)
}
is SetupState.Created,
is SetupState.CreatedAndSaved ->
@@ -114,6 +192,25 @@ private fun subtitle(state: SecureBackupSetupState): String {
private fun Content(
state: SecureBackupSetupState,
) {
// Spinner until the well-known resolves, so neither flow renders before we know which applies.
if (!state.wellknownLoaded && state.setupState == SetupState.Init) {
LoadingPlaceholder()
return
}
if (state.isCustomEntry()) {
when (state.setupState) {
SetupState.Init,
is SetupState.Error -> when (state.customEntryStep) {
CustomEntryStep.Entry -> CustomPassphraseEntry(state = state)
CustomEntryStep.Confirm -> CustomPassphraseConfirm(state = state)
}
// Hold the spinner through the auto-skip so the SDK base58 key is never shown/shared.
SetupState.Creating,
is SetupState.Created,
is SetupState.CreatedAndSaved -> LoadingPlaceholder()
}
return
}
val context = LocalContext.current
val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey
val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard)
@@ -144,10 +241,269 @@ private fun Content(
)
}
@Composable
private fun LoadingPlaceholder() {
val description = stringResource(id = R.string.a11y_recovery_key_loading_specs)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 52.dp)
.semantics { contentDescription = description },
contentAlignment = Alignment.Center,
) {
// CircularProgressIndicator applies progressSemantics() internally — no need to add it here.
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
}
}
@Composable
private fun CustomPassphraseEntry(state: SecureBackupSetupState) {
val specs = state.customRecoveryPassphraseRequirements ?: return
var passphraseVisible by rememberSaveable { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
TextField(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.customRecoveryPassphrase)
.semantics { contentType = ContentType.NewPassword },
value = state.customPassphrase,
onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(it)) },
label = stringResource(id = CommonStrings.common_recovery_key),
singleLine = true,
visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
PasswordVisibilityToggle(
visible = passphraseVisible,
onToggle = { passphraseVisible = !passphraseVisible },
)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
if (state.canContinueFromEntry) {
state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase)
}
},
),
supportingText = pluralStringResource(
id = R.plurals.screen_recovery_key_custom_requirement_length,
count = specs.minCharacterCount,
specs.minCharacterCount,
),
)
// Always render the strength row in the Entry step (matches iOS): an empty field shows the
// "Strength" base label with an empty bar; typing flips it to the per-level label + bar.
Spacer(modifier = Modifier.height(8.dp))
PassphraseStrengthIndicator(
result = state.customPassphraseStrength,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun PassphraseStrengthIndicator(
result: CustomRecoveryPassphraseStrengthResult?,
modifier: Modifier = Modifier,
) {
val label = stringResource(
id = result?.strength?.labelRes() ?: R.string.screen_recovery_key_custom_strength_base,
)
val hint = result?.strength?.hintRes()?.let { stringResource(id = it) }
val score = result?.score ?: 0f
// Mirror iOS: a zero score (empty field or the Garbage tier) stays neutral; otherwise the
// label and bar share a single colour interpolated along the red→orange→yellow→green gradient.
val color = if (score <= 0f) ElementTheme.colors.textSecondary else passphraseStrengthColor(score)
val announcement = stringResource(id = R.string.a11y_recovery_key_custom_strength_announcement, label)
.let { if (hint != null) "$it. $hint" else it }
Column(
modifier = modifier.semantics(mergeDescendants = true) {
contentDescription = announcement
},
) {
Text(
text = label,
color = color,
style = ElementTheme.typography.fontBodySmMedium,
)
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { score },
modifier = Modifier.fillMaxWidth(),
color = color,
)
if (hint != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = hint,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
}
private fun CustomRecoveryPassphraseStrength.labelRes(): Int = when (this) {
CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_garbage
CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_weak
CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_moderate
CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_okay
CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_strong
CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_very_strong
CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_ultra_strong
CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_mega
}
private fun CustomRecoveryPassphraseStrength.hintRes(): Int = when (this) {
CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_hint_garbage
CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_hint_weak
CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_hint_moderate
CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_hint_okay
CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_hint_strong
CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_hint_very_strong
CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_hint_ultra_strong
CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_hint_mega
}
// Stops mirror iOS PasswordStrengthBar.gradient: Compound red900 / lime700 with the same vivid
// orange & yellow midpoints. Intentionally fixed (not light/dark semantic) so the gradient reads
// identically to iOS in both themes.
private val passphraseStrengthGradientStops = listOf(
0.25f to Color(0xFFD51928), // Compound red900
0.5f to Color(0xFFFF9500), // orange
0.75f to Color(0xFFFFCC00), // yellow
1.0f to Color(0xFF54C424), // Compound lime700
)
/** Interpolates the strength bar colour along [passphraseStrengthGradientStops] for a 0f..1f score. */
private fun passphraseStrengthColor(score: Float): Color {
val clamped = score.coerceIn(0f, 1f)
var previousLocation = 0f
var previousColor = passphraseStrengthGradientStops.first().second
for ((location, color) in passphraseStrengthGradientStops) {
if (clamped <= location) {
val span = location - previousLocation
if (span <= 0f) return color
return lerp(previousColor, color, ((clamped - previousLocation) / span).coerceIn(0f, 1f))
}
previousLocation = location
previousColor = color
}
return previousColor
}
@Composable
private fun CustomPassphraseConfirm(state: SecureBackupSetupState) {
var passphraseVisible by rememberSaveable { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
TextField(
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.customRecoveryPassphraseConfirm)
.semantics { contentType = ContentType.NewPassword },
value = state.customPassphraseConfirm,
onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(it)) },
label = stringResource(id = CommonStrings.common_recovery_key),
singleLine = true,
visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
PasswordVisibilityToggle(
visible = passphraseVisible,
onToggle = { passphraseVisible = !passphraseVisible },
)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
if (state.canSubmitCustomPassphrase) {
state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase)
}
},
),
validity = if (state.customPassphraseMismatch) TextFieldValidity.Invalid else TextFieldValidity.None,
supportingText = if (state.customPassphraseMismatch) {
stringResource(id = R.string.screen_recovery_key_custom_mismatch)
} else {
null
},
)
}
}
@Composable
private fun ColumnScope.Buttons(
state: SecureBackupSetupState,
onFinish: () -> Unit,
onCancel: () -> Unit,
) {
// No buttons until the well-known resolves; Content shows a spinner meanwhile.
if (!state.wellknownLoaded && state.setupState == SetupState.Init) return
if (state.isCustomEntry()) {
CustomButtons(state = state, onCancel = onCancel)
} else {
AutoGenButtons(state = state, onFinish = onFinish)
}
}
@Composable
private fun ColumnScope.CustomButtons(
state: SecureBackupSetupState,
onCancel: () -> Unit,
) {
// No save/share controls in the custom flow — they'd leak the base58 key during Created.
when (state.setupState) {
SetupState.Creating,
is SetupState.Created,
is SetupState.CreatedAndSaved -> return
SetupState.Init,
is SetupState.Error -> Unit
}
when (state.customEntryStep) {
CustomEntryStep.Entry -> {
Button(
text = stringResource(id = CommonStrings.action_continue),
enabled = state.canContinueFromEntry,
modifier = Modifier.fillMaxWidth(),
onClick = { state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) },
)
TextButton(
text = stringResource(id = CommonStrings.action_cancel),
modifier = Modifier.fillMaxWidth(),
onClick = onCancel,
)
}
CustomEntryStep.Confirm -> {
Button(
text = stringResource(id = R.string.screen_recovery_key_custom_finish_action),
enabled = state.canSubmitCustomPassphrase,
modifier = Modifier.fillMaxWidth(),
onClick = { state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) },
)
}
}
}
@Composable
private fun ColumnScope.AutoGenButtons(
state: SecureBackupSetupState,
onFinish: () -> Unit,
) {
val context = LocalContext.current
val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action)
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<string name="screen_recovery_key_custom_title">Enter a custom recovery key</string>
<string name="screen_recovery_key_custom_description">Choose a recovery key that you can memorize.</string>
<string name="screen_recovery_key_custom_confirm_title">Confirm your recovery key</string>
<string name="screen_recovery_key_custom_confirm_description">Enter your recovery key again.</string>
<string name="screen_recovery_key_custom_mismatch">The recovery key you entered doesn\'t match</string>
<plurals name="screen_recovery_key_custom_requirement_length">
<item quantity="one">Minimum %1$d character</item>
<item quantity="other">Minimum %1$d characters</item>
</plurals>
<string name="screen_recovery_key_custom_finish_action">Finish setup</string>
<string name="screen_recovery_key_custom_strength_base">Strength</string>
<string name="screen_recovery_key_custom_strength_garbage">Garbage</string>
<string name="screen_recovery_key_custom_strength_weak">Weak</string>
<string name="screen_recovery_key_custom_strength_moderate">Moderate</string>
<string name="screen_recovery_key_custom_strength_okay">Okay</string>
<string name="screen_recovery_key_custom_strength_strong">Strong</string>
<string name="screen_recovery_key_custom_strength_very_strong">Very strong</string>
<string name="screen_recovery_key_custom_strength_ultra_strong">Super strong</string>
<string name="screen_recovery_key_custom_strength_mega">Ideal</string>
<string name="screen_recovery_key_custom_strength_hint_garbage">Cracking would take roughly milliseconds.</string>
<string name="screen_recovery_key_custom_strength_hint_weak">Cracking would take roughly minutes to months.</string>
<string name="screen_recovery_key_custom_strength_hint_moderate">Cracking would take roughly years.</string>
<string name="screen_recovery_key_custom_strength_hint_okay">Cracking would take roughly hundreds of years.</string>
<string name="screen_recovery_key_custom_strength_hint_strong">Cracking would take roughly billions of years.</string>
<string name="screen_recovery_key_custom_strength_hint_very_strong">Cracking would take roughly trillions of years.</string>
<string name="screen_recovery_key_custom_strength_hint_ultra_strong">Cracking would take roughly quadrillions of years.</string>
<string name="screen_recovery_key_custom_strength_hint_mega">Cracking would take roughly quintillions of years.</string>
<string name="a11y_recovery_key_loading_specs">Loading recovery key requirements</string>
<string name="a11y_recovery_key_custom_strength_announcement">Passphrase strength: %1$s</string>
</resources>