mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
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:
+1
-1
Submodule enterprise updated: 6781da90aa...0a17f4b5c9
+5
-9
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
package io.element.android.features.logout.impl
|
package io.element.android.features.logout.impl
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -60,6 +59,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|||||||
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
|
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
|
||||||
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.Icon
|
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.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||||
@@ -280,14 +280,10 @@ private fun Content(
|
|||||||
placeholder = stringResource(CommonStrings.common_password),
|
placeholder = stringResource(CommonStrings.common_password),
|
||||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
val image =
|
PasswordVisibilityToggle(
|
||||||
if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
|
visible = passwordVisible,
|
||||||
val description =
|
onToggle = { passwordVisible = !passwordVisible },
|
||||||
if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
|
)
|
||||||
|
|
||||||
Box(modifier = Modifier.clickable { passwordVisible = !passwordVisible }) {
|
|
||||||
Icon(imageVector = image, description)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Password,
|
keyboardType = KeyboardType.Password,
|
||||||
|
|||||||
+5
-10
@@ -60,6 +60,7 @@ 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.Icon
|
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.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
@@ -249,16 +250,10 @@ private fun LoginForm(
|
|||||||
placeholder = stringResource(CommonStrings.common_password),
|
placeholder = stringResource(CommonStrings.common_password),
|
||||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
val image =
|
PasswordVisibilityToggle(
|
||||||
if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
|
visible = passwordVisible,
|
||||||
val description =
|
onToggle = { passwordVisible = !passwordVisible },
|
||||||
if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
|
)
|
||||||
Box(Modifier.clickable { passwordVisible = !passwordVisible }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = image,
|
|
||||||
contentDescription = description,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Password,
|
keyboardType = KeyboardType.Password,
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* 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.api
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
|
||||||
|
interface SecureBackupSetupEntryPoint : FeatureEntryPoint {
|
||||||
|
data class Inputs(val isChangeRecoveryKeyUserStory: Boolean) : NodeInputs
|
||||||
|
|
||||||
|
fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
inputs: Inputs,
|
||||||
|
): Node
|
||||||
|
}
|
||||||
+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 dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
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.disable.SecureBackupDisableNode
|
||||||
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
|
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.reset.ResetIdentityFlowNode
|
||||||
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
|
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.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.appyx.canPop
|
import io.element.android.libraries.architecture.appyx.canPop
|
||||||
@@ -39,6 +39,7 @@ import kotlinx.parcelize.Parcelize
|
|||||||
class SecureBackupFlowNode(
|
class SecureBackupFlowNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val secureBackupSetupEntryPoint: SecureBackupSetupEntryPoint,
|
||||||
) : BaseFlowNode<SecureBackupFlowNode.NavTarget>(
|
) : BaseFlowNode<SecureBackupFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = when (plugins.filterIsInstance<SecureBackupEntryPoint.Params>().first().initialElement) {
|
initialElement = when (plugins.filterIsInstance<SecureBackupEntryPoint.Params>().first().initialElement) {
|
||||||
@@ -97,16 +98,18 @@ class SecureBackupFlowNode(
|
|||||||
createNode<SecureBackupRootNode>(buildContext, listOf(callback))
|
createNode<SecureBackupRootNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.Setup -> {
|
NavTarget.Setup -> {
|
||||||
val inputs = SecureBackupSetupNode.Inputs(
|
secureBackupSetupEntryPoint.createNode(
|
||||||
isChangeRecoveryKeyUserStory = false,
|
parentNode = this,
|
||||||
|
buildContext = buildContext,
|
||||||
|
inputs = SecureBackupSetupEntryPoint.Inputs(isChangeRecoveryKeyUserStory = false),
|
||||||
)
|
)
|
||||||
createNode<SecureBackupSetupNode>(buildContext, listOf(inputs))
|
|
||||||
}
|
}
|
||||||
NavTarget.Change -> {
|
NavTarget.Change -> {
|
||||||
val inputs = SecureBackupSetupNode.Inputs(
|
secureBackupSetupEntryPoint.createNode(
|
||||||
isChangeRecoveryKeyUserStory = true,
|
parentNode = this,
|
||||||
|
buildContext = buildContext,
|
||||||
|
inputs = SecureBackupSetupEntryPoint.Inputs(isChangeRecoveryKeyUserStory = true),
|
||||||
)
|
)
|
||||||
createNode<SecureBackupSetupNode>(buildContext, listOf(inputs))
|
|
||||||
}
|
}
|
||||||
NavTarget.Disable -> {
|
NavTarget.Disable -> {
|
||||||
createNode<SecureBackupDisableNode>(buildContext)
|
createNode<SecureBackupDisableNode>(buildContext)
|
||||||
|
|||||||
+2
-32
@@ -9,22 +9,10 @@
|
|||||||
package io.element.android.features.securebackup.impl.enter
|
package io.element.android.features.securebackup.impl.enter
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.isImeVisible
|
|
||||||
import androidx.compose.foundation.layout.padding
|
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.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.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
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
|
||||||
@@ -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.atomic.pages.FlowStepPage
|
||||||
import io.element.android.libraries.designsystem.components.BigIcon
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
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.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.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SecureBackupEnterRecoveryKeyView(
|
fun SecureBackupEnterRecoveryKeyView(
|
||||||
@@ -71,29 +57,13 @@ fun SecureBackupEnterRecoveryKeyView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Content(
|
private fun Content(
|
||||||
state: SecureBackupEnterRecoveryKeyState,
|
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(
|
RecoveryKeyView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.bringIntoViewOnImeVisible()
|
||||||
.bringIntoViewRequester(bringIntoViewRequester)
|
|
||||||
.padding(top = 52.dp, bottom = 32.dp),
|
.padding(top = 52.dp, bottom = 32.dp),
|
||||||
state = state.recoveryKeyViewState,
|
state = state.recoveryKeyViewState,
|
||||||
onClick = null,
|
onClick = null,
|
||||||
|
|||||||
+5
-11
@@ -8,8 +8,6 @@
|
|||||||
|
|
||||||
package io.element.android.features.securebackup.impl.reset.password
|
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.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.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.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.TextField
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
|
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@@ -92,14 +90,10 @@ private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Bool
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
val image =
|
PasswordVisibilityToggle(
|
||||||
if (showPassword) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
|
visible = showPassword,
|
||||||
val description =
|
onToggle = { showPassword = !showPassword },
|
||||||
if (showPassword) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
|
)
|
||||||
|
|
||||||
Box(Modifier.clickable { showPassword = !showPassword }) {
|
|
||||||
Icon(imageVector = image, description)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None,
|
validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None,
|
||||||
supportingText = if (hasError) {
|
supportingText = if (hasError) {
|
||||||
|
|||||||
+5
-11
@@ -9,7 +9,6 @@
|
|||||||
package io.element.android.features.securebackup.impl.setup.views
|
package io.element.android.features.securebackup.impl.setup.views
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
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.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.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||||
import io.element.android.libraries.testtags.TestTags
|
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),
|
placeholder = stringResource(id = R.string.screen_recovery_key_confirm_key_placeholder),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
val image =
|
PasswordVisibilityToggle(
|
||||||
if (state.displayTextFieldContents) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
|
visible = state.displayTextFieldContents,
|
||||||
val description =
|
onToggle = { toggleRecoveryKeyVisibility(!state.displayTextFieldContents) },
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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_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_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_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_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>
|
<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(
|
SecureBackupFlowNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
|
secureBackupSetupEntryPoint = DefaultSecureBackupSetupEntryPoint(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : SecureBackupEntryPoint.Callback {
|
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
|
@Test
|
||||||
fun `present - handle errors`() = runTest {
|
fun `present - handle errors`() = runTest {
|
||||||
val encryptionService = FakeEncryptionService(
|
val encryptionService = FakeEncryptionService(
|
||||||
enableRecoveryLambda = { Result.failure(IllegalStateException("Test error")) }
|
enableRecoveryLambda = { _, _ -> Result.failure(IllegalStateException("Test error")) }
|
||||||
)
|
)
|
||||||
val presenter = createSecureBackupSetupPresenter(
|
val presenter = createSecureBackupSetupPresenter(
|
||||||
isChangeRecoveryKeyUserStory = false,
|
isChangeRecoveryKeyUserStory = false,
|
||||||
@@ -189,7 +189,7 @@ class SecureBackupSetupPresenterTest {
|
|||||||
private fun createSecureBackupSetupPresenter(
|
private fun createSecureBackupSetupPresenter(
|
||||||
isChangeRecoveryKeyUserStory: Boolean = false,
|
isChangeRecoveryKeyUserStory: Boolean = false,
|
||||||
encryptionService: EncryptionService = FakeEncryptionService(
|
encryptionService: EncryptionService = FakeEncryptionService(
|
||||||
enableRecoveryLambda = { Result.success(Unit) },
|
enableRecoveryLambda = { _, _ -> Result.success("") },
|
||||||
),
|
),
|
||||||
): SecureBackupSetupPresenter {
|
): SecureBackupSetupPresenter {
|
||||||
return SecureBackupSetupPresenter(
|
return SecureBackupSetupPresenter(
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2025 New Vector 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.libraries.designsystem.modifiers
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.isImeVisible
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the focused field visible above the keyboard. Intended for a field inside a scrollable
|
||||||
|
* container: the field is never clipped by a pinned footer, but the IME is shown only after focus
|
||||||
|
* arrives, so we re-request bringIntoView once it is visible to scroll the field back into the
|
||||||
|
* reduced viewport.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.bringIntoViewOnImeVisible(): Modifier {
|
||||||
|
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
val isImeVisible = WindowInsets.isImeVisible
|
||||||
|
LaunchedEffect(isImeVisible, isFocused) {
|
||||||
|
if (isImeVisible && isFocused) {
|
||||||
|
// Delay to ensure the keyboard is fully shown before scrolling the field into view.
|
||||||
|
delay(100.milliseconds)
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
.bringIntoViewRequester(bringIntoViewRequester)
|
||||||
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* 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.libraries.designsystem.theme.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show/hide toggle for a password [TextField], intended for its `trailingIcon` slot.
|
||||||
|
* Shared so every password field reveals plaintext the same way and announces the
|
||||||
|
* same accessibility labels.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PasswordVisibilityToggle(
|
||||||
|
visible: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier.clickable(onClick = onToggle)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (visible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff(),
|
||||||
|
contentDescription = stringResource(
|
||||||
|
if (visible) CommonStrings.a11y_hide_password else CommonStrings.a11y_show_password
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-21
@@ -18,39 +18,35 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.libraries.designsystem.theme.components.Snackbar
|
import io.element.android.libraries.designsystem.theme.components.Snackbar
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
|
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
|
||||||
|
*
|
||||||
|
* The current head of the queue is exposed as a [MutableStateFlow] so that every collector — including
|
||||||
|
* hosts that subscribe *after* a message was posted (e.g. a screen recomposing as a flow pops back to
|
||||||
|
* it) — observes the current message. An earlier mutex-gated implementation delivered each message to
|
||||||
|
* whichever single collector happened to be parked on the lock, which could starve the host that was
|
||||||
|
* actually on screen, dropping the snackbar.
|
||||||
*/
|
*/
|
||||||
class SnackbarDispatcher {
|
class SnackbarDispatcher {
|
||||||
private val queueMutex = Mutex()
|
|
||||||
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
|
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
|
||||||
val snackbarMessage: Flow<SnackbarMessage?> = flow {
|
private val currentMessage = MutableStateFlow<SnackbarMessage?>(null)
|
||||||
while (currentCoroutineContext().isActive) {
|
|
||||||
queueMutex.lock()
|
|
||||||
emit(snackBarMessageQueue.firstOrNull())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
val snackbarMessage: Flow<SnackbarMessage?> = currentMessage.asStateFlow()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun post(message: SnackbarMessage) {
|
fun post(message: SnackbarMessage) {
|
||||||
if (snackBarMessageQueue.isEmpty()) {
|
snackBarMessageQueue.add(message)
|
||||||
snackBarMessageQueue.add(message)
|
currentMessage.value = snackBarMessageQueue.firstOrNull()
|
||||||
if (queueMutex.isLocked) queueMutex.unlock()
|
|
||||||
} else {
|
|
||||||
snackBarMessageQueue.add(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun clear() {
|
fun clear() {
|
||||||
if (snackBarMessageQueue.isNotEmpty()) {
|
snackBarMessageQueue.removeFirstOrNull()
|
||||||
snackBarMessageQueue.removeFirstOrNull()
|
currentMessage.value = snackBarMessageQueue.firstOrNull()
|
||||||
if (queueMutex.isLocked) queueMutex.unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
@@ -54,6 +54,27 @@ class SnackbarDispatcherTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a host that subscribes after a message is posted still receives it`() = runTest {
|
||||||
|
val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
val message = SnackbarMessage(0)
|
||||||
|
// A first host is already collecting (mirrors a background screen still subscribed).
|
||||||
|
snackbarDispatcher.snackbarMessage.test {
|
||||||
|
assertThat(awaitItem()).isNull()
|
||||||
|
snackbarDispatcher.post(message)
|
||||||
|
assertThat(awaitItem()).isEqualTo(message)
|
||||||
|
|
||||||
|
// A second host subscribes only afterwards — e.g. the screen a flow pops back to once the
|
||||||
|
// message has already been posted. It must observe the current message rather than being
|
||||||
|
// starved waiting for the next post (regression: an earlier mutex-gated implementation
|
||||||
|
// delivered each post to a single parked collector, dropping the snackbar for any host
|
||||||
|
// that subscribed later).
|
||||||
|
snackbarDispatcher.snackbarMessage.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
|
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
|
||||||
val snackbarDispatcher = SnackbarDispatcher()
|
val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
|||||||
+8
-2
@@ -24,9 +24,15 @@ interface EncryptionService {
|
|||||||
suspend fun enableBackups(): Result<Unit>
|
suspend fun enableBackups(): Result<Unit>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable recovery. Observe enableProgressStateFlow to get progress and recovery key.
|
* Enable recovery and return the SDK-generated recovery key on success.
|
||||||
|
* Observe [enableRecoveryProgressStateFlow] for in-progress UI updates.
|
||||||
|
*
|
||||||
|
* @param waitForBackupsToUpload when true, suspends until existing room keys finish uploading.
|
||||||
|
* @param passphrase optional user-supplied passphrase. When set, the SDK derives the
|
||||||
|
* secret-storage key from it instead of a random base58 key; the passphrase can later be
|
||||||
|
* passed to [recover], and the returned base58 key should not be surfaced to the user.
|
||||||
*/
|
*/
|
||||||
suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result<Unit>
|
suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String? = null): Result<String>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change the recovery and return the new recovery key.
|
* Change the recovery and return the new recovery key.
|
||||||
|
|||||||
+14
-5
@@ -131,19 +131,28 @@ class RustEncryptionService(
|
|||||||
|
|
||||||
override suspend fun enableRecovery(
|
override suspend fun enableRecovery(
|
||||||
waitForBackupsToUpload: Boolean,
|
waitForBackupsToUpload: Boolean,
|
||||||
): Result<Unit> = withContext(dispatchers.io) {
|
passphrase: String?,
|
||||||
|
): Result<String> = withContext(dispatchers.io) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
service.enableRecovery(
|
// The key arrives as the suspend return value (like resetRecoveryKey), avoiding a
|
||||||
|
// flow/return-value race; the listener only feeds sub-progress.
|
||||||
|
enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Starting
|
||||||
|
val key = service.enableRecovery(
|
||||||
waitForBackupsToUpload = waitForBackupsToUpload,
|
waitForBackupsToUpload = waitForBackupsToUpload,
|
||||||
progressListener = object : EnableRecoveryProgressListener {
|
progressListener = object : EnableRecoveryProgressListener {
|
||||||
override fun onUpdate(status: RustEnableRecoveryProgress) {
|
override fun onUpdate(status: RustEnableRecoveryProgress) {
|
||||||
enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status)
|
enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
passphrase = null,
|
passphrase = passphrase,
|
||||||
)
|
)
|
||||||
// enableRecovery returns the encryption key, but we read it from the state flow
|
// Pin Done explicitly so observers get a coherent terminal value. For the passphrase
|
||||||
.let { }
|
// path the user never sees the SDK base58 key, so keep it out of this session-scoped
|
||||||
|
// (in-memory) flow entirely; the caller still receives it as the return value and is
|
||||||
|
// responsible for scrubbing it. The auto-generated path must retain the key here since
|
||||||
|
// that is how it is surfaced to the user.
|
||||||
|
enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Done(if (passphrase != null) "" else key)
|
||||||
|
key
|
||||||
}.mapFailure {
|
}.mapFailure {
|
||||||
it.mapRecoveryException()
|
it.mapRecoveryException()
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -28,7 +28,8 @@ class FakeEncryptionService(
|
|||||||
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
|
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||||
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
|
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
|
||||||
private val getUserIdentityResult: (UserId) -> Result<IdentityState?> = { lambdaError() },
|
private val getUserIdentityResult: (UserId) -> Result<IdentityState?> = { lambdaError() },
|
||||||
private val enableRecoveryLambda: (Boolean) -> Result<Unit> = { lambdaError() },
|
private val enableRecoveryLambda: (Boolean, String?) -> Result<String> = { _, _ -> lambdaError() },
|
||||||
|
private val resetRecoveryKeyLambda: () -> Result<String> = { Result.success(FAKE_RECOVERY_KEY) },
|
||||||
) : EncryptionService {
|
) : EncryptionService {
|
||||||
private var disableRecoveryFailure: Exception? = null
|
private var disableRecoveryFailure: Exception? = null
|
||||||
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
||||||
@@ -90,11 +91,11 @@ class FakeEncryptionService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun resetRecoveryKey(): Result<String> = simulateLongTask {
|
override suspend fun resetRecoveryKey(): Result<String> = simulateLongTask {
|
||||||
return Result.success(FAKE_RECOVERY_KEY)
|
return resetRecoveryKeyLambda()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result<Unit> = simulateLongTask {
|
override suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String?): Result<String> = simulateLongTask {
|
||||||
return enableRecoveryLambda(waitForBackupsToUpload)
|
return enableRecoveryLambda(waitForBackupsToUpload, passphrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun givenWaitForBackupUploadSteadyStateFlow(flow: Flow<BackupUploadState>) {
|
fun givenWaitForBackupUploadSteadyStateFlow(flow: Flow<BackupUploadState>) {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ object TestTags {
|
|||||||
* Verification screen.
|
* Verification screen.
|
||||||
*/
|
*/
|
||||||
val recoveryKey = TestTag("verification-recovery_key")
|
val recoveryKey = TestTag("verification-recovery_key")
|
||||||
|
val customRecoveryPassphrase = TestTag("verification-custom_recovery_passphrase")
|
||||||
|
val customRecoveryPassphraseConfirm = TestTag("verification-custom_recovery_passphrase_confirm")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign out screen.
|
* Sign out screen.
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* 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.libraries.wellknown.api
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-driven settings for a user-chosen recovery passphrase, advertised under the well-known
|
||||||
|
* `custom_recovery_passphrase` key. Today the only rule is a minimum character count; additional
|
||||||
|
* settings can be added here as the schema grows.
|
||||||
|
*/
|
||||||
|
data class CustomRecoveryPassphrase(
|
||||||
|
val minCharacterCount: Int,
|
||||||
|
) {
|
||||||
|
/** True when [input] meets every active rule. */
|
||||||
|
fun isSatisfiedBy(input: String): Boolean = isSatisfiedBy(input.length)
|
||||||
|
|
||||||
|
/** True when an input of [length] characters meets every active rule. */
|
||||||
|
fun isSatisfiedBy(length: Int): Boolean = length >= minCharacterCount
|
||||||
|
}
|
||||||
+1
@@ -15,4 +15,5 @@ data class ElementWellKnown(
|
|||||||
val brandColor: String?,
|
val brandColor: String?,
|
||||||
val notificationSound: String?,
|
val notificationSound: String?,
|
||||||
val identityProviderAppScheme: String?,
|
val identityProviderAppScheme: String?,
|
||||||
|
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
|
||||||
)
|
)
|
||||||
|
|||||||
+17
-1
@@ -15,7 +15,15 @@ import kotlinx.serialization.Serializable
|
|||||||
* Example:
|
* Example:
|
||||||
* <pre>
|
* <pre>
|
||||||
* {
|
* {
|
||||||
* "registration_helper_url": "https://element.io"
|
* "registration_helper_url": "https://element.io",
|
||||||
|
* "enforce_element_pro": true,
|
||||||
|
* "rageshake_url": "https://example.org/rageshake",
|
||||||
|
* "brand_color": "#FF0000",
|
||||||
|
* "notification_sound": "ring.flac",
|
||||||
|
* "idp_app_scheme": "io.element.app",
|
||||||
|
* "custom_recovery_passphrase": {
|
||||||
|
* "min_character_count": 8
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
* </pre>
|
* </pre>
|
||||||
* .
|
* .
|
||||||
@@ -34,4 +42,12 @@ data class InternalElementWellKnown(
|
|||||||
val notificationSound: String? = null,
|
val notificationSound: String? = null,
|
||||||
@SerialName("idp_app_scheme")
|
@SerialName("idp_app_scheme")
|
||||||
val identityProviderAppScheme: String? = null,
|
val identityProviderAppScheme: String? = null,
|
||||||
|
@SerialName("custom_recovery_passphrase")
|
||||||
|
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class InternalCustomRecoveryPassphrase(
|
||||||
|
@SerialName("min_character_count")
|
||||||
|
val minCharacterCount: Int? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+28
@@ -8,7 +8,19 @@
|
|||||||
|
|
||||||
package io.element.android.libraries.wellknown.impl
|
package io.element.android.libraries.wellknown.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
|
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
|
||||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
private val loggerTag = LoggerTag("Wellknown")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor for the recovery passphrase minimum length. A value of 1 guarantees the derived passphrase is
|
||||||
|
* never empty (an empty passphrase derives a recoverable but effectively unprotected secret-storage
|
||||||
|
* key). Anything stronger than non-empty is left to the homeserver operator's configuration.
|
||||||
|
*/
|
||||||
|
private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1
|
||||||
|
|
||||||
internal fun InternalElementWellKnown.map() = ElementWellKnown(
|
internal fun InternalElementWellKnown.map() = ElementWellKnown(
|
||||||
registrationHelperUrl = registrationHelperUrl,
|
registrationHelperUrl = registrationHelperUrl,
|
||||||
@@ -17,4 +29,20 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown(
|
|||||||
brandColor = brandColor,
|
brandColor = brandColor,
|
||||||
notificationSound = notificationSound,
|
notificationSound = notificationSound,
|
||||||
identityProviderAppScheme = identityProviderAppScheme,
|
identityProviderAppScheme = identityProviderAppScheme,
|
||||||
|
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
|
||||||
|
// Whenever the homeserver advertises the settings block we run the custom passphrase flow, flooring
|
||||||
|
// the minimum at 1 so the passphrase can never be empty even if the server omits min_character_count
|
||||||
|
// or advertises a non-positive value. The operator owns any stronger minimum.
|
||||||
|
val min = (minCharacterCount ?: MINIMUM_PASSPHRASE_CHARACTER_COUNT).coerceAtLeast(MINIMUM_PASSPHRASE_CHARACTER_COUNT)
|
||||||
|
if (min != minCharacterCount) {
|
||||||
|
Timber.tag(loggerTag.value).w(
|
||||||
|
"custom_recovery_passphrase.min_character_count was %s; flooring to %d",
|
||||||
|
minCharacterCount,
|
||||||
|
min,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return CustomRecoveryPassphrase(minCharacterCount = min)
|
||||||
|
}
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* 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.libraries.wellknown.api
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CustomRecoveryPassphraseTest {
|
||||||
|
@Test
|
||||||
|
fun `empty input never satisfies a positive minimum`() {
|
||||||
|
assertThat(CustomRecoveryPassphrase(minCharacterCount = 1).isSatisfiedBy("")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `input shorter than minimum is not satisfied`() {
|
||||||
|
assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `input exactly at minimum is satisfied`() {
|
||||||
|
assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `input longer than minimum is satisfied`() {
|
||||||
|
assertThat(CustomRecoveryPassphrase(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -19,6 +19,7 @@ import io.element.android.libraries.cachestore.api.CacheStore
|
|||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
|
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
|
||||||
|
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
|
||||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
@@ -51,6 +52,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
brandColor = null,
|
brandColor = null,
|
||||||
notificationSound = null,
|
notificationSound = null,
|
||||||
identityProviderAppScheme = null,
|
identityProviderAppScheme = null,
|
||||||
|
customRecoveryPassphrase = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -76,6 +78,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
brandColor = "#FF0000",
|
brandColor = "#FF0000",
|
||||||
notificationSound = "a_notification_sound.flac",
|
notificationSound = "a_notification_sound.flac",
|
||||||
identityProviderAppScheme = "an_app_scheme",
|
identityProviderAppScheme = "an_app_scheme",
|
||||||
|
customRecoveryPassphrase = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -105,6 +108,93 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
brandColor = null,
|
brandColor = null,
|
||||||
notificationSound = null,
|
notificationSound = null,
|
||||||
identityProviderAppScheme = null,
|
identityProviderAppScheme = null,
|
||||||
|
customRecoveryPassphrase = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get element wellknown with custom recovery passphrase settings`() = runTest {
|
||||||
|
val sut = createDefaultSessionWellknownRetriever(
|
||||||
|
getUrlLambda = {
|
||||||
|
Result.success(
|
||||||
|
"""{
|
||||||
|
"custom_recovery_passphrase": {
|
||||||
|
"min_character_count": 8
|
||||||
|
}
|
||||||
|
}""".trimIndent().toByteArray()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||||
|
WellknownRetrieverResult.Success(
|
||||||
|
anElementWellKnown(
|
||||||
|
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get element wellknown with custom recovery passphrase settings missing min character count floors to 1`() = runTest {
|
||||||
|
val sut = createDefaultSessionWellknownRetriever(
|
||||||
|
getUrlLambda = {
|
||||||
|
Result.success(
|
||||||
|
"""{
|
||||||
|
"custom_recovery_passphrase": {}
|
||||||
|
}""".trimIndent().toByteArray()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||||
|
WellknownRetrieverResult.Success(
|
||||||
|
anElementWellKnown(
|
||||||
|
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get element wellknown with zero min character count floors to 1`() = runTest {
|
||||||
|
val sut = createDefaultSessionWellknownRetriever(
|
||||||
|
getUrlLambda = {
|
||||||
|
Result.success(
|
||||||
|
"""{
|
||||||
|
"custom_recovery_passphrase": {
|
||||||
|
"min_character_count": 0
|
||||||
|
}
|
||||||
|
}""".trimIndent().toByteArray()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||||
|
WellknownRetrieverResult.Success(
|
||||||
|
anElementWellKnown(
|
||||||
|
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get element wellknown with negative min character count floors to 1`() = runTest {
|
||||||
|
val sut = createDefaultSessionWellknownRetriever(
|
||||||
|
getUrlLambda = {
|
||||||
|
Result.success(
|
||||||
|
"""{
|
||||||
|
"custom_recovery_passphrase": {
|
||||||
|
"min_character_count": -5
|
||||||
|
}
|
||||||
|
}""".trimIndent().toByteArray()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||||
|
WellknownRetrieverResult.Success(
|
||||||
|
anElementWellKnown(
|
||||||
|
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -157,6 +247,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
brandColor = "#FF0000",
|
brandColor = "#FF0000",
|
||||||
notificationSound = "a_notification_sound.flac",
|
notificationSound = "a_notification_sound.flac",
|
||||||
identityProviderAppScheme = "an_app_scheme",
|
identityProviderAppScheme = "an_app_scheme",
|
||||||
|
customRecoveryPassphrase = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -210,6 +301,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
brandColor = "#FF0000",
|
brandColor = "#FF0000",
|
||||||
notificationSound = "a_notification_sound.flac",
|
notificationSound = "a_notification_sound.flac",
|
||||||
identityProviderAppScheme = "an_app_scheme",
|
identityProviderAppScheme = "an_app_scheme",
|
||||||
|
customRecoveryPassphrase = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+9
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
package io.element.android.features.wellknown.test
|
package io.element.android.features.wellknown.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
|
||||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||||
|
|
||||||
fun anElementWellKnown(
|
fun anElementWellKnown(
|
||||||
@@ -17,6 +18,7 @@ fun anElementWellKnown(
|
|||||||
brandColor: String? = null,
|
brandColor: String? = null,
|
||||||
notificationSound: String? = null,
|
notificationSound: String? = null,
|
||||||
identityProviderAppScheme: String? = null,
|
identityProviderAppScheme: String? = null,
|
||||||
|
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
|
||||||
) = ElementWellKnown(
|
) = ElementWellKnown(
|
||||||
registrationHelperUrl = registrationHelperUrl,
|
registrationHelperUrl = registrationHelperUrl,
|
||||||
enforceElementPro = enforceElementPro,
|
enforceElementPro = enforceElementPro,
|
||||||
@@ -24,4 +26,11 @@ fun anElementWellKnown(
|
|||||||
brandColor = brandColor,
|
brandColor = brandColor,
|
||||||
notificationSound = notificationSound,
|
notificationSound = notificationSound,
|
||||||
identityProviderAppScheme = identityProviderAppScheme,
|
identityProviderAppScheme = identityProviderAppScheme,
|
||||||
|
customRecoveryPassphrase = customRecoveryPassphrase,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aCustomRecoveryPassphrase(
|
||||||
|
minCharacterCount: Int = 8,
|
||||||
|
) = CustomRecoveryPassphrase(
|
||||||
|
minCharacterCount = minCharacterCount,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -317,6 +317,7 @@
|
|||||||
"screen_chat_backup_.*",
|
"screen_chat_backup_.*",
|
||||||
"screen_key_backup_disable_.*",
|
"screen_key_backup_disable_.*",
|
||||||
"screen_recovery_key_.*",
|
"screen_recovery_key_.*",
|
||||||
|
"screen\\.custom_recovery_key\\..*",
|
||||||
"screen_create_new_recovery_key_.*",
|
"screen_create_new_recovery_key_.*",
|
||||||
"screen_encryption_reset.*",
|
"screen_encryption_reset.*",
|
||||||
"screen_reset_encryption.*",
|
"screen_reset_encryption.*",
|
||||||
|
|||||||
Reference in New Issue
Block a user