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:
+34
@@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -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)
|
||||
|
||||
+2
-32
@@ -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,
|
||||
|
||||
+5
-11
@@ -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) {
|
||||
|
||||
+5
-11
@@ -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 that’s stored only on the server"</string>
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ class DefaultSecureBackupEntryPointTest {
|
||||
SecureBackupFlowNode(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
secureBackupSetupEntryPoint = DefaultSecureBackupSetupEntryPoint(),
|
||||
)
|
||||
}
|
||||
val callback = object : SecureBackupEntryPoint.Callback {
|
||||
|
||||
+57
@@ -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))
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user