Merge pull request #6944 from element-hq/feature/custom-recovery-key-wellknown

Add well-known parsing and extension seam for custom recovery passphrase
This commit is contained in:
Benoit Marty
2026-06-17 13:58:22 +02:00
committed by GitHub
29 changed files with 525 additions and 116 deletions
@@ -0,0 +1,34 @@
/*
* 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
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.securebackup.api.SecureBackupSetupEntryPoint
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultSecureBackupSetupEntryPoint : SecureBackupSetupEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
inputs: SecureBackupSetupEntryPoint.Inputs,
): Node {
return parentNode.createNode<SecureBackupSetupNode>(
buildContext = buildContext,
plugins = listOf(
SecureBackupSetupNode.Inputs(
isChangeRecoveryKeyUserStory = inputs.isChangeRecoveryKeyUserStory,
)
),
)
}
}
@@ -21,11 +21,11 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.securebackup.api.SecureBackupSetupEntryPoint
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
import io.element.android.features.securebackup.impl.reset.ResetIdentityFlowNode
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.canPop
@@ -39,6 +39,7 @@ import kotlinx.parcelize.Parcelize
class SecureBackupFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val secureBackupSetupEntryPoint: SecureBackupSetupEntryPoint,
) : BaseFlowNode<SecureBackupFlowNode.NavTarget>(
backstack = BackStack(
initialElement = when (plugins.filterIsInstance<SecureBackupEntryPoint.Params>().first().initialElement) {
@@ -97,16 +98,18 @@ class SecureBackupFlowNode(
createNode<SecureBackupRootNode>(buildContext, listOf(callback))
}
NavTarget.Setup -> {
val inputs = SecureBackupSetupNode.Inputs(
isChangeRecoveryKeyUserStory = false,
secureBackupSetupEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = SecureBackupSetupEntryPoint.Inputs(isChangeRecoveryKeyUserStory = false),
)
createNode<SecureBackupSetupNode>(buildContext, listOf(inputs))
}
NavTarget.Change -> {
val inputs = SecureBackupSetupNode.Inputs(
isChangeRecoveryKeyUserStory = true,
secureBackupSetupEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = SecureBackupSetupEntryPoint.Inputs(isChangeRecoveryKeyUserStory = true),
)
createNode<SecureBackupSetupNode>(buildContext, listOf(inputs))
}
NavTarget.Disable -> {
createNode<SecureBackupDisableNode>(buildContext)
@@ -9,22 +9,10 @@
package io.element.android.features.securebackup.impl.enter
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -34,13 +22,11 @@ import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.modifiers.bringIntoViewOnImeVisible
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.ui.strings.CommonStrings
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun SecureBackupEnterRecoveryKeyView(
@@ -71,29 +57,13 @@ fun SecureBackupEnterRecoveryKeyView(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Content(
state: SecureBackupEnterRecoveryKeyState,
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var isFocused by remember { mutableStateOf(false) }
val isImeVisible = WindowInsets.isImeVisible
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(isImeVisible, isFocused) {
// When the keyboard is shown, we want to scroll the text field into view
if (isImeVisible && isFocused) {
coroutineScope.launch {
// Delay to ensure the keyboard is fully shown
delay(100.milliseconds)
bringIntoViewRequester.bringIntoView()
}
}
}
RecoveryKeyView(
modifier = Modifier
.onFocusChanged { isFocused = it.isFocused }
.bringIntoViewRequester(bringIntoViewRequester)
.bringIntoViewOnImeVisible()
.padding(top = 52.dp, bottom = 32.dp),
state = state.recoveryKeyViewState,
onClick = null,
@@ -8,8 +8,6 @@
package io.element.android.features.securebackup.impl.reset.password
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -32,7 +30,7 @@ import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusN
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.Icon
import io.element.android.libraries.designsystem.theme.components.PasswordVisibilityToggle
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.ui.strings.CommonStrings
@@ -92,14 +90,10 @@ private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Bool
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (showPassword) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
val description =
if (showPassword) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
Box(Modifier.clickable { showPassword = !showPassword }) {
Icon(imageVector = image, description)
}
PasswordVisibilityToggle(
visible = showPassword,
onToggle = { showPassword = !showPassword },
)
},
validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None,
supportingText = if (hasError) {
@@ -9,7 +9,6 @@
package io.element.android.features.securebackup.impl.setup.views
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -50,6 +49,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
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.TextField
import io.element.android.libraries.testtags.TestTags
@@ -223,16 +223,10 @@ private fun RecoveryKeyFormContent(
),
placeholder = stringResource(id = R.string.screen_recovery_key_confirm_key_placeholder),
trailingIcon = {
val image =
if (state.displayTextFieldContents) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
val description =
if (state.displayTextFieldContents) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
Box(Modifier.clickable { toggleRecoveryKeyVisibility(!state.displayTextFieldContents) }) {
Icon(
imageVector = image,
contentDescription = description,
)
}
PasswordVisibilityToggle(
visible = state.displayTextFieldContents,
onToggle = { toggleRecoveryKeyVisibility(!state.displayTextFieldContents) },
)
},
)
}
@@ -20,6 +20,24 @@
<string name="screen_create_new_recovery_key_list_item_4">"Follow the instructions to create a new recovery key"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Save your new recovery key in a password manager or encrypted note"</string>
<string name="screen_create_new_recovery_key_title">"Reset the encryption for your account using another device"</string>
<string name="screen_custom_recovery_key_confirm_error_mismatch">"The recovery key you entered doesn\'t match"</string>
<string name="screen_custom_recovery_key_confirm_submit">"Finish setup"</string>
<string name="screen_custom_recovery_key_confirm_subtitle">"Enter your recovery key again."</string>
<string name="screen_custom_recovery_key_confirm_title">"Confirm your recovery key"</string>
<string name="screen_custom_recovery_key_input_notice">"Minimum %1$s characters. Do not use your account password"</string>
<string name="screen_custom_recovery_key_input_strength">"Strength"</string>
<string name="screen_custom_recovery_key_input_strength_a11y">"Passphrase strength: %1$s"</string>
<string name="screen_custom_recovery_key_input_strength_moderate">"Moderate"</string>
<string name="screen_custom_recovery_key_input_strength_strong">"Strong"</string>
<string name="screen_custom_recovery_key_input_strength_very_strong">"Very strong"</string>
<string name="screen_custom_recovery_key_input_strength_very_weak">"Very weak"</string>
<string name="screen_custom_recovery_key_input_strength_weak">"Weak"</string>
<string name="screen_custom_recovery_key_input_subtitle">"Choose a recovery key that you can memorize."</string>
<string name="screen_custom_recovery_key_input_title">"Enter a recovery key"</string>
<string name="screen_custom_recovery_key_loading_a11y">"Loading recovery key requirements"</string>
<string name="screen_custom_recovery_key_success_notice">"To change your recovery key, go to Settings → Encryption → Backup"</string>
<string name="screen_custom_recovery_key_success_subtitle">"You can use your recovery key to confirm new devices or restore your encrypted chats if you lose access to all your devices."</string>
<string name="screen_custom_recovery_key_success_title">"Your backup is now fully set up"</string>
<string name="screen_encryption_reset_action_continue_reset">"Continue reset"</string>
<string name="screen_encryption_reset_bullet_1">"Your account details, contacts, preferences, and chat list will be kept"</string>
<string name="screen_encryption_reset_bullet_2">"You will lose any message history thats stored only on the server"</string>
@@ -32,6 +32,7 @@ class DefaultSecureBackupEntryPointTest {
SecureBackupFlowNode(
buildContext = buildContext,
plugins = plugins,
secureBackupSetupEntryPoint = DefaultSecureBackupSetupEntryPoint(),
)
}
val callback = object : SecureBackupEntryPoint.Callback {
@@ -0,0 +1,57 @@
/*
* 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
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.securebackup.api.SecureBackupSetupEntryPoint
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupPresenter
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupStateMachine
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultSecureBackupSetupEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultSecureBackupSetupEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
SecureBackupSetupNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = object : SecureBackupSetupPresenter.Factory {
override fun create(isChangeRecoveryKeyUserStory: Boolean) = SecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
stateMachine = SecureBackupSetupStateMachine(),
encryptionService = FakeEncryptionService(),
)
},
snackbarDispatcher = SnackbarDispatcher(),
)
}
val inputs = SecureBackupSetupEntryPoint.Inputs(isChangeRecoveryKeyUserStory = true)
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
inputs = inputs,
)
assertThat(result).isInstanceOf(SecureBackupSetupNode::class.java)
assertThat(result.plugins).contains(SecureBackupSetupNode.Inputs(isChangeRecoveryKeyUserStory = true))
}
}
@@ -117,7 +117,7 @@ class SecureBackupSetupPresenterTest {
@Test
fun `present - handle errors`() = runTest {
val encryptionService = FakeEncryptionService(
enableRecoveryLambda = { Result.failure(IllegalStateException("Test error")) }
enableRecoveryLambda = { _, _ -> Result.failure(IllegalStateException("Test error")) }
)
val presenter = createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = false,
@@ -189,7 +189,7 @@ class SecureBackupSetupPresenterTest {
private fun createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory: Boolean = false,
encryptionService: EncryptionService = FakeEncryptionService(
enableRecoveryLambda = { Result.success(Unit) },
enableRecoveryLambda = { _, _ -> Result.success("") },
),
): SecureBackupSetupPresenter {
return SecureBackupSetupPresenter(