From 748454f34da44414e2215a2be5570d542659d42d Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 2 Jun 2026 13:33:04 -0700 Subject: [PATCH 01/23] Add shared PasswordVisibilityToggle design-system component Extract the show/hide password trailing-icon into a reusable PasswordVisibilityToggle so every password field reveals plaintext the same way and announces the same a11y labels. Reuse it from the login and reset-identity password fields. Pure refactor, no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../loginpassword/LoginPasswordView.kt | 15 +++----- .../password/ResetIdentityPasswordView.kt | 16 +++----- .../components/PasswordVisibilityToggle.kt | 37 +++++++++++++++++++ 3 files changed, 47 insertions(+), 21 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/PasswordVisibilityToggle.kt diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index d3641ea749..feb1d3d53a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -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.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.Scaffold import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar @@ -249,16 +250,10 @@ private fun LoginForm( placeholder = stringResource(CommonStrings.common_password), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - val image = - if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff() - val description = - if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password) - Box(Modifier.clickable { passwordVisible = !passwordVisible }) { - Icon( - imageVector = image, - contentDescription = description, - ) - } + PasswordVisibilityToggle( + visible = passwordVisible, + onToggle = { passwordVisible = !passwordVisible }, + ) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt index 8d32433ac5..3bb77ac206 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt @@ -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) { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/PasswordVisibilityToggle.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/PasswordVisibilityToggle.kt new file mode 100644 index 0000000000..9154ec6292 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/PasswordVisibilityToggle.kt @@ -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 + ), + ) + } +} From 2931418018d1f6b15cf542361f412ae2e2d74cf8 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 2 Jun 2026 13:33:18 -0700 Subject: [PATCH 02/23] Parse custom_recovery_passphrase_settings from Element .well-known Add CustomRecoveryPassphraseRequirements to ElementWellKnown and decode the custom_recovery_passphrase_settings block (currently just min_character_count) from the homeserver's element.json. Invalid specs (missing or non-positive min_character_count) are logged and mapped to null so the setup flow falls back to the generated-key path. Field names match the ESS well-known schema (element-enterprise#261). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CustomRecoveryPassphraseRequirements.kt | 23 ++++++ .../wellknown/api/ElementWellKnown.kt | 1 + .../impl/InternalElementWellKnown.kt | 18 ++++- .../libraries/wellknown/impl/Mapper.kt | 18 +++++ ...ustomRecoveryPassphraseRequirementsTest.kt | 33 ++++++++ .../DefaultSessionWellknownRetrieverTest.kt | 80 +++++++++++++++++++ .../features/wellknown/test/Fixtures.kt | 9 +++ 7 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt create mode 100644 libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt new file mode 100644 index 0000000000..dc6241b0a9 --- /dev/null +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt @@ -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 requirements for a user-chosen recovery passphrase. Today the only rule + * is a minimum character count; additional rules can be added here as the well-known + * schema (`custom_recovery_passphrase_settings`) grows. + */ +data class CustomRecoveryPassphraseRequirements( + 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 +} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt index 4c1c476c7a..ac050ba94a 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt @@ -15,4 +15,5 @@ data class ElementWellKnown( val brandColor: String?, val notificationSound: String?, val identityProviderAppScheme: String?, + val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?, ) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt index 2e2b5de16f..803e498eda 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt @@ -15,7 +15,15 @@ import kotlinx.serialization.Serializable * Example: *
  * {
- *     "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_settings": {
+ *         "min_character_count": 8
+ *     }
  * }
  * 
* . @@ -34,4 +42,12 @@ data class InternalElementWellKnown( val notificationSound: String? = null, @SerialName("idp_app_scheme") val identityProviderAppScheme: String? = null, + @SerialName("custom_recovery_passphrase_settings") + val customRecoveryPassphraseRequirements: InternalCustomRecoveryPassphraseRequirements? = null, +) + +@Serializable +data class InternalCustomRecoveryPassphraseRequirements( + @SerialName("min_character_count") + val minCharacterCount: Int? = null, ) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt index 41ed54d7db..61899578d7 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt @@ -8,7 +8,12 @@ package io.element.android.libraries.wellknown.impl +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements import io.element.android.libraries.wellknown.api.ElementWellKnown +import timber.log.Timber + +private val loggerTag = LoggerTag("Wellknown") internal fun InternalElementWellKnown.map() = ElementWellKnown( registrationHelperUrl = registrationHelperUrl, @@ -17,4 +22,17 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown( brandColor = brandColor, notificationSound = notificationSound, identityProviderAppScheme = identityProviderAppScheme, + customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements?.toPublic(), ) + +private fun InternalCustomRecoveryPassphraseRequirements.toPublic(): CustomRecoveryPassphraseRequirements? { + val min = minCharacterCount ?: run { + Timber.tag(loggerTag.value).w("custom_recovery_passphrase_settings missing min_character_count; ignoring spec") + return null + } + if (min <= 0) { + Timber.tag(loggerTag.value).w("custom_recovery_passphrase_settings.min_character_count must be > 0; ignoring spec") + return null + } + return CustomRecoveryPassphraseRequirements(minCharacterCount = min) +} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt new file mode 100644 index 0000000000..c8f10fe2ca --- /dev/null +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt @@ -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 CustomRecoveryPassphraseRequirementsTest { + @Test + fun `empty input never satisfies a positive minimum`() { + assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 1).isSatisfiedBy("")).isFalse() + } + + @Test + fun `input shorter than minimum is not satisfied`() { + assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse() + } + + @Test + fun `input exactly at minimum is satisfied`() { + assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue() + } + + @Test + fun `input longer than minimum is satisfied`() { + assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue() + } +} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt index cca40e283f..68d4d818c2 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt @@ -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.FakeMatrixClient import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.toolbox.api.systemclock.SystemClock @@ -51,6 +52,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = null, notificationSound = null, identityProviderAppScheme = null, + customRecoveryPassphraseRequirements = null, ) ) ) @@ -76,6 +78,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", + customRecoveryPassphraseRequirements = null, ) ) ) @@ -105,11 +108,86 @@ class DefaultSessionWellknownRetrieverTest { brandColor = null, notificationSound = null, identityProviderAppScheme = null, + customRecoveryPassphraseRequirements = null, ) ) ) } + @Test + fun `get element wellknown with custom recovery passphrase requirements`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success( + """{ + "custom_recovery_passphrase_settings": { + "min_character_count": 8 + } + }""".trimIndent().toByteArray() + ) + }, + ) + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 8) + ) + ) + ) + } + + @Test + fun `get element wellknown with custom recovery passphrase requirements missing min character count maps to null`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success( + """{ + "custom_recovery_passphrase_settings": {} + }""".trimIndent().toByteArray() + ) + }, + ) + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success(anElementWellKnown()) + ) + } + + @Test + fun `get element wellknown with non-positive min character count maps to null`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success( + """{ + "custom_recovery_passphrase_settings": { + "min_character_count": 0 + } + }""".trimIndent().toByteArray() + ) + }, + ) + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success(anElementWellKnown()) + ) + } + + @Test + fun `get element wellknown with negative min character count maps to null`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success( + """{ + "custom_recovery_passphrase_settings": { + "min_character_count": -5 + } + }""".trimIndent().toByteArray() + ) + }, + ) + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success(anElementWellKnown()) + ) + } + @Test fun `get element wellknown json error`() = runTest { val sut = createDefaultSessionWellknownRetriever( @@ -157,6 +235,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", + customRecoveryPassphraseRequirements = null, ) ) ) @@ -210,6 +289,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", + customRecoveryPassphraseRequirements = null, ) ) ) diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt index ae7d1c629c..1ed69207ba 100644 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt @@ -8,6 +8,7 @@ package io.element.android.features.wellknown.test +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements import io.element.android.libraries.wellknown.api.ElementWellKnown fun anElementWellKnown( @@ -17,6 +18,7 @@ fun anElementWellKnown( brandColor: String? = null, notificationSound: String? = null, identityProviderAppScheme: String? = null, + customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements? = null, ) = ElementWellKnown( registrationHelperUrl = registrationHelperUrl, enforceElementPro = enforceElementPro, @@ -24,4 +26,11 @@ fun anElementWellKnown( brandColor = brandColor, notificationSound = notificationSound, identityProviderAppScheme = identityProviderAppScheme, + customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements, +) + +fun aCustomRecoveryPassphraseRequirements( + minCharacterCount: Int = 8, +) = CustomRecoveryPassphraseRequirements( + minCharacterCount = minCharacterCount, ) From 62ac6a3d2f6f6a2fa6c9201fdf54f96386acdc2f Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 2 Jun 2026 13:51:59 -0700 Subject: [PATCH 03/23] Add enterprise gate for custom recovery passphrase Add isCustomRecoveryPassphraseEnabled() and estimateCustomRecoveryPassphraseStrength() to EnterpriseService, plus the CustomRecoveryPassphraseStrength result types. FOSS builds return false/null so the feature stays gated off; the enterprise implementation (estimator) lands separately in the enterprise repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/CustomRecoveryPassphraseStrength.kt | 35 +++++++++++++++++++ .../enterprise/api/EnterpriseService.kt | 16 +++++++++ .../impl/DefaultEnterpriseService.kt | 5 +++ .../enterprise/test/FakeEnterpriseService.kt | 8 +++++ 4 files changed, 64 insertions(+) create mode 100644 features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/CustomRecoveryPassphraseStrength.kt diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/CustomRecoveryPassphraseStrength.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/CustomRecoveryPassphraseStrength.kt new file mode 100644 index 0000000000..ea8b02d93f --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/CustomRecoveryPassphraseStrength.kt @@ -0,0 +1,35 @@ +/* + * 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.enterprise.api + +/** + * Strength tier surfaced to the user under a custom recovery passphrase field. + * The eight tiers mirror iOS `PasswordStrengthThreshold` 1:1 so both clients label a given + * passphrase identically; each maps to its own label and time-to-crack description in the UI. + */ +enum class CustomRecoveryPassphraseStrength { + Garbage, + Weak, + Moderate, + Okay, + Strong, + VeryStrong, + UltraStrong, + Mega, +} + +/** + * Result of estimating a custom recovery passphrase's strength. The estimation algorithm itself + * is an enterprise capability ([EnterpriseService.estimateCustomRecoveryPassphraseStrength]); FOSS + * builds never produce one. + */ +data class CustomRecoveryPassphraseStrengthResult( + val strength: CustomRecoveryPassphraseStrength, + /** Fill amount for the progress bar. Always in `0f..1f`. */ + val score: Float, +) diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 92d8b9b646..ce56a5dc80 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -41,6 +41,22 @@ interface EnterpriseService { */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? + /** + * Whether the user is allowed to set a custom recovery passphrase. + * Enterprise builds gate this in addition to the homeserver's .well-known `customRecoveryPassphraseRequirements`; + * when false the setup flow falls back to the auto-generated key path even if the homeserver + * advertises a spec. + */ + fun isCustomRecoveryPassphraseEnabled(): Boolean + + /** + * Estimate the strength of a user-typed custom recovery [passphrase]. + * The estimation algorithm is an enterprise capability: FOSS builds return null, so callers + * should treat null as "no indicator to show". Callers are responsible for not calling this with + * an empty passphrase if they want the indicator hidden while the field is empty. + */ + fun estimateCustomRecoveryPassphraseStrength(passphrase: String): CustomRecoveryPassphraseStrengthResult? + companion object { const val ANY_ACCOUNT_PROVIDER = "*" } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index 6e3ed5d3cc..caa2eb291f 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -13,6 +13,7 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl +import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow @@ -45,4 +46,8 @@ class DefaultEnterpriseService : EnterpriseService { } override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null + + override fun isCustomRecoveryPassphraseEnabled(): Boolean = false + + override fun estimateCustomRecoveryPassphraseStrength(passphrase: String): CustomRecoveryPassphraseStrengthResult? = null } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index 805c75be6a..fb474a96e5 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -11,6 +11,7 @@ package io.element.android.features.enterprise.test import androidx.compose.ui.graphics.Color import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl +import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.tests.testutils.lambda.lambdaError @@ -31,6 +32,8 @@ class FakeEnterpriseService( private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() }, + private val isCustomRecoveryPassphraseEnabledResult: Boolean = false, + private val estimateCustomRecoveryPassphraseStrengthResult: (String) -> CustomRecoveryPassphraseStrengthResult? = { null }, ) : EnterpriseService { private val brandColorState = MutableStateFlow(initialBrandColor) private val semanticColorsState = MutableStateFlow(initialSemanticColors) @@ -79,4 +82,9 @@ class FakeEnterpriseService( override fun getNoisyNotificationChannelId(sessionId: SessionId): String? { return getNoisyNotificationChannelIdResult(sessionId) } + + override fun isCustomRecoveryPassphraseEnabled(): Boolean = isCustomRecoveryPassphraseEnabledResult + + override fun estimateCustomRecoveryPassphraseStrength(passphrase: String): CustomRecoveryPassphraseStrengthResult? = + estimateCustomRecoveryPassphraseStrengthResult(passphrase) } From 0cb9d2911de86ed5407c3c4965055a91bd197656 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 2 Jun 2026 13:52:08 -0700 Subject: [PATCH 04/23] Allow enableRecovery to derive the key from a passphrase enableRecovery now takes an optional passphrase and returns the recovery key as the suspend result (like resetRecoveryKey), avoiding the flow/return-value race. For the passphrase path the SDK-derived base58 key is kept out of the session-scoped progress flow so it is never surfaced to the user; the caller receives it as the return value and is responsible for scrubbing it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/encryption/EncryptionService.kt | 10 ++++++++-- .../impl/encryption/RustEncryptionService.kt | 19 ++++++++++++++----- .../test/encryption/FakeEncryptionService.kt | 9 +++++---- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index 333cfb709b..f82cdc1969 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -24,9 +24,15 @@ interface EncryptionService { suspend fun enableBackups(): Result /** - * 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 + suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String? = null): Result /** * Change the recovery and return the new recovery key. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index f67b7cb7e1..74f235f9f8 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -131,19 +131,28 @@ class RustEncryptionService( override suspend fun enableRecovery( waitForBackupsToUpload: Boolean, - ): Result = withContext(dispatchers.io) { + passphrase: String?, + ): Result = withContext(dispatchers.io) { 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, progressListener = object : EnableRecoveryProgressListener { override fun onUpdate(status: RustEnableRecoveryProgress) { enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status) } }, - passphrase = null, + passphrase = passphrase, ) - // enableRecovery returns the encryption key, but we read it from the state flow - .let { } + // Pin Done explicitly so observers get a coherent terminal value. For the passphrase + // 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 { it.mapRecoveryException() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index 04e3779298..e090319821 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -28,7 +28,8 @@ class FakeEncryptionService( private val pinUserIdentityResult: (UserId) -> Result = { lambdaError() }, private val withdrawVerificationResult: (UserId) -> Result = { lambdaError() }, private val getUserIdentityResult: (UserId) -> Result = { lambdaError() }, - private val enableRecoveryLambda: (Boolean) -> Result = { lambdaError() }, + private val enableRecoveryLambda: (Boolean, String?) -> Result = { _, _ -> lambdaError() }, + private val resetRecoveryKeyLambda: () -> Result = { Result.success(FAKE_RECOVERY_KEY) }, ) : EncryptionService { private var disableRecoveryFailure: Exception? = null override val backupStateStateFlow: MutableStateFlow = MutableStateFlow(BackupState.UNKNOWN) @@ -90,11 +91,11 @@ class FakeEncryptionService( } override suspend fun resetRecoveryKey(): Result = simulateLongTask { - return Result.success(FAKE_RECOVERY_KEY) + return resetRecoveryKeyLambda() } - override suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result = simulateLongTask { - return enableRecoveryLambda(waitForBackupsToUpload) + override suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String?): Result = simulateLongTask { + return enableRecoveryLambda(waitForBackupsToUpload, passphrase) } fun givenWaitForBackupUploadSteadyStateFlow(flow: Flow) { From db6f4414df4de42c91415f8b91c8493637648a92 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 2 Jun 2026 13:52:21 -0700 Subject: [PATCH 05/23] Add custom recovery passphrase setup flow When the homeserver advertises custom_recovery_passphrase_settings (and the enterprise gate is enabled), the secure-backup setup lets the user enter and confirm their own recovery key with a live strength indicator, instead of receiving a generated one. The SDK derives the 4S key from the passphrase and the base58 key is scrubbed everywhere so it is never shown. Falls back to the generated-key flow when no spec is present or the well-known fetch fails. Snapshot PNGs are intentionally left out; the core team regenerates them after the PR is opened. Co-Authored-By: Claude Opus 4.8 (1M context) --- features/securebackup/impl/build.gradle.kts | 3 + .../impl/setup/CustomPassphraseDerivations.kt | 51 ++ .../impl/setup/SecureBackupSetupEvents.kt | 18 + .../impl/setup/SecureBackupSetupPresenter.kt | 206 ++++- .../impl/setup/SecureBackupSetupState.kt | 24 + .../setup/SecureBackupSetupStateMachine.kt | 6 + .../setup/SecureBackupSetupStateProvider.kt | 140 +++- .../impl/setup/SecureBackupSetupView.kt | 378 ++++++++- .../impl/src/main/res/values/temporary.xml | 37 + .../setup/SecureBackupSetupPresenterTest.kt | 758 +++++++++++++++++- .../android/libraries/testtags/TestTags.kt | 2 + 11 files changed, 1552 insertions(+), 71 deletions(-) create mode 100644 features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt create mode 100644 features/securebackup/impl/src/main/res/values/temporary.xml diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts index 54d87ef22e..d7dafc3e65 100644 --- a/features/securebackup/impl/build.gradle.kts +++ b/features/securebackup/impl/build.gradle.kts @@ -38,9 +38,12 @@ dependencies { implementation(projects.libraries.oauth.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) + implementation(projects.libraries.wellknown.api) api(libs.statemachine) api(projects.features.securebackup.api) testCommonDependencies(libs, true) + testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.wellknown.test) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt new file mode 100644 index 0000000000..7bf6e5805c --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup + +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements + +/** + * Validation flags computed once and shared by the presenter and the preview provider. + * + * Note: passphrase strength is intentionally NOT derived here. The estimation algorithm is an + * enterprise capability ([io.element.android.features.enterprise.api.EnterpriseService.estimateCustomRecoveryPassphraseStrength]); + * the presenter computes it via the injected service and the state provider supplies samples directly. + */ +internal data class CustomPassphraseDerivations( + val meetsMinLength: Boolean, + val mismatch: Boolean, + val canContinueFromEntry: Boolean, + val canSubmitCustomPassphrase: Boolean, +) + +internal fun deriveCustomPassphraseState( + requirements: CustomRecoveryPassphraseRequirements?, + passphrase: String, + confirm: String, + step: CustomEntryStep, + setupState: SetupState, +): CustomPassphraseDerivations { + val meetsMinLength = requirements?.isSatisfiedBy(passphrase) ?: true + val mismatch = confirm.isNotEmpty() && passphrase != confirm + val canContinueFromEntry = requirements != null && + passphrase.isNotEmpty() && + meetsMinLength && + setupState is SetupState.Init + val canSubmit = requirements != null && + passphrase.isNotEmpty() && + passphrase == confirm && + meetsMinLength && + step == CustomEntryStep.Confirm && + setupState is SetupState.Init + return CustomPassphraseDerivations( + meetsMinLength = meetsMinLength, + mismatch = mismatch, + canContinueFromEntry = canContinueFromEntry, + canSubmitCustomPassphrase = canSubmit, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt index f61e65ba61..faf3d618fe 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt @@ -13,4 +13,22 @@ sealed interface SecureBackupSetupEvents { data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents data object Done : SecureBackupSetupEvents data object DismissDialog : SecureBackupSetupEvents + + /** Update the user-typed custom recovery passphrase. */ + data class UpdateCustomPassphrase(val value: String) : SecureBackupSetupEvents + + /** Update the user-typed confirmation field. */ + data class UpdateCustomPassphraseConfirm(val value: String) : SecureBackupSetupEvents + + /** Advance from the Entry step to the Confirm step. No-op unless the entry passphrase meets requirements. */ + data object ContinueCustomPassphrase : SecureBackupSetupEvents + + /** Step back from Confirm to Entry. Both typed values are preserved. */ + data object BackToCustomEntry : SecureBackupSetupEvents + + /** Submit the custom passphrase to the SDK (only valid when [SecureBackupSetupState.canSubmitCustomPassphrase]). */ + data object SubmitCustomPassphrase : SecureBackupSetupEvents + + /** Abort an in-flight custom submit (back press while creating) and return to the Confirm step. */ + data object CancelCustomPassphraseSubmit : SecureBackupSetupEvents } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt index 9f27bc9566..9145b4a49b 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt @@ -11,6 +11,7 @@ package io.element.android.features.securebackup.impl.setup import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,22 +23,33 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.securebackup.impl.loggerTagSetup import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements +import io.element.android.libraries.wellknown.api.SessionWellknownRetriever +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import timber.log.Timber +private data class WellknownStatus( + val loaded: Boolean, + val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?, +) + @AssistedInject class SecureBackupSetupPresenter( @Assisted private val isChangeRecoveryKeyUserStory: Boolean, private val stateMachine: SecureBackupSetupStateMachine, private val encryptionService: EncryptionService, + private val sessionWellknownRetriever: SessionWellknownRetriever, + private val enterpriseService: EnterpriseService, ) : Presenter { @AssistedFactory interface Factory { @@ -53,10 +65,81 @@ class SecureBackupSetupPresenter( } var showSaveConfirmationDialog by remember { mutableStateOf(false) } + // Spinner until the well-known fetch settles, so the user can't start the auto-gen path + // while a custom spec is still in flight. The loaded/specs pair flips in one assignment. + val wellknownStatusState = remember { + mutableStateOf(WellknownStatus(loaded = false, customRecoveryPassphraseRequirements = null)) + } + val wellknownLoaded by remember { + derivedStateOf { wellknownStatusState.value.loaded } + } + val customRecoveryPassphraseRequirements by remember { + derivedStateOf { wellknownStatusState.value.customRecoveryPassphraseRequirements } + } + // Not rememberSaveable: the passphrase must never reach the on-disk saved-state bundle. + // Plain String (not a zeroed CharArray): the TextField/event/SDK boundary all copy Strings + // anyway, so we just clear it on success and accept loss on process death. + var customPassphrase by remember { mutableStateOf("") } + var customPassphraseConfirm by remember { mutableStateOf("") } + var customEntryStep by remember { mutableStateOf(CustomEntryStep.Entry) } + // Single-flight guard: a button tap and an IME-Done in the same frame both see + // canSubmit=true and would each launch an enableRecovery coroutine. The state machine + // dedupes UserCreatesKey, but only this flag stops the duplicate SDK call. + var customSubmitInFlight by remember { mutableStateOf(false) } + // Handle to the in-flight custom submit so a back press can abort it (see CancelCustomPassphraseSubmit). + var customSubmitJob by remember { mutableStateOf(null) } + + LaunchedEffect(setupState) { + if (setupState !is SetupState.Creating) { + customSubmitInFlight = false + } + } + + LaunchedEffect(Unit) { + val result = sessionWellknownRetriever.getElementWellKnown() + // Enterprise gate: even if the homeserver advertises a custom spec, only honor it on + // builds where the feature is enabled. FOSS builds always fall back to the auto-gen path. + val specsFromWellknown = (result as? WellknownRetrieverResult.Success)?.data?.customRecoveryPassphraseRequirements + wellknownStatusState.value = WellknownStatus( + loaded = true, + customRecoveryPassphraseRequirements = specsFromWellknown.takeIf { enterpriseService.isCustomRecoveryPassphraseEnabled() }, + ) + } + + // Shared with SecureBackupSetupStateProvider so previews never drift from runtime. + val derivations by remember { + derivedStateOf { + deriveCustomPassphraseState( + requirements = wellknownStatusState.value.customRecoveryPassphraseRequirements, + passphrase = customPassphrase, + confirm = customPassphraseConfirm, + step = customEntryStep, + setupState = setupState, + ) + } + } + + // Strength is an enterprise-only estimation; null while the field is empty or in FOSS builds. + // Only computed on the Entry step, the only place the indicator is rendered. + val customPassphraseStrength by remember { + derivedStateOf { + customPassphrase + .takeIf { customEntryStep == CustomEntryStep.Entry && it.isNotEmpty() } + ?.let { enterpriseService.estimateCustomRecoveryPassphraseStrength(it) } + } + } + + // Nothing to "save" when the user chose the passphrase: auto-skip the Created step. + LaunchedEffect(setupState, customRecoveryPassphraseRequirements) { + if (customRecoveryPassphraseRequirements != null && setupState is SetupState.Created) { + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) + } + } + fun handleEvent(event: SecureBackupSetupEvents) { when (event) { SecureBackupSetupEvents.CreateRecoveryKey -> { - coroutineScope.createOrChangeRecoveryKey(stateAndDispatch) + coroutineScope.createOrChangeRecoveryKey(stateAndDispatch, passphrase = null) } SecureBackupSetupEvents.RecoveryKeyHasBeenSaved -> stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) @@ -67,12 +150,51 @@ class SecureBackupSetupPresenter( SecureBackupSetupEvents.Done -> { showSaveConfirmationDialog = true } + is SecureBackupSetupEvents.UpdateCustomPassphrase -> { + customPassphrase = event.value + } + is SecureBackupSetupEvents.UpdateCustomPassphraseConfirm -> { + customPassphraseConfirm = event.value + } + SecureBackupSetupEvents.ContinueCustomPassphrase -> { + if (derivations.canContinueFromEntry) { + customEntryStep = CustomEntryStep.Confirm + } + } + SecureBackupSetupEvents.BackToCustomEntry -> { + customEntryStep = CustomEntryStep.Entry + } + SecureBackupSetupEvents.SubmitCustomPassphrase -> { + if (derivations.canSubmitCustomPassphrase && !customSubmitInFlight) { + customSubmitInFlight = true + customSubmitJob = coroutineScope.createOrChangeRecoveryKey( + stateAndDispatch, + passphrase = customPassphrase, + onSuccess = { + customPassphrase = "" + customPassphraseConfirm = "" + }, + ) + } + } + SecureBackupSetupEvents.CancelCustomPassphraseSubmit -> { + // Abort the SDK call and snap back to Initial. Dispatch the reset first so that + // if the (now cancelled) coroutine still manages to dispatch SdkError/ + // SdkHasCreatedKey, those land in Initial where the state machine ignores them. + customSubmitJob?.cancel() + customSubmitJob = null + customSubmitInFlight = false + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCancelledCreate) + } } } + val isCustomFlow = customRecoveryPassphraseRequirements != null val recoveryKeyViewState = RecoveryKeyViewState( recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup, - formattedRecoveryKey = setupState.recoveryKey(), + // Custom flow: never surface the SDK base58 key in view state, even during the + // brief Created/CreatedAndSaved auto-skip window. + formattedRecoveryKey = if (isCustomFlow) null else setupState.recoveryKey(), displayTextFieldContents = true, inProgress = setupState is SetupState.Creating, ) @@ -82,6 +204,16 @@ class SecureBackupSetupPresenter( recoveryKeyViewState = recoveryKeyViewState, setupState = setupState, showSaveConfirmationDialog = showSaveConfirmationDialog, + wellknownLoaded = wellknownLoaded, + customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements, + customEntryStep = customEntryStep, + customPassphrase = customPassphrase, + customPassphraseConfirm = customPassphraseConfirm, + customPassphraseMeetsMinLength = derivations.meetsMinLength, + customPassphraseMismatch = derivations.mismatch, + customPassphraseStrength = customPassphraseStrength, + canContinueFromEntry = derivations.canContinueFromEntry, + canSubmitCustomPassphrase = derivations.canSubmitCustomPassphrase, eventSink = ::handleEvent, ) } @@ -98,47 +230,43 @@ class SecureBackupSetupPresenter( } private fun CoroutineScope.createOrChangeRecoveryKey( - stateAndDispatch: StateAndDispatch + stateAndDispatch: StateAndDispatch, + passphrase: String?, + onSuccess: () -> Unit = {}, ) = launch { stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey) - if (isChangeRecoveryKeyUserStory) { + // Custom passphrase → enableRecovery(passphrase): the SDK derives the 4S key from it. + // For the Change flow this rotates in place — the SDK skips backup creation when recovery + // is already enabled (confirmed), so there's no key-backup teardown or room-key re-upload. + val result = if (passphrase != null) { + Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=present)") + encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = passphrase) + } else if (isChangeRecoveryKeyUserStory) { Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()") - encryptionService.resetRecoveryKey().fold( - onSuccess = { - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it)) - }, - onFailure = { - if (it is Exception) { - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it)) - } - } - ) + encryptionService.resetRecoveryKey() } else { - observeEncryptionService(stateAndDispatch) - Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()") - encryptionService.enableRecovery(waitForBackupsToUpload = false).onFailure { + Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=absent)") + encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = null) + } + result.fold( + onSuccess = { key -> + // Clear buffers only on success; on failure keep them so the user can retry + // without retyping. + onSuccess() + // Custom flow: scrub the SDK base58 key from the state machine so SetupState + // never carries it. RustEncryptionService likewise keeps it out of + // enableRecoveryProgressStateFlow for the passphrase path, so it is not retained + // anywhere once this local goes out of scope. + val storedKey = if (passphrase != null) "" else key + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(storedKey)) + }, + onFailure = { Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery") - if (it is Exception) { - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it)) - } + // The state machine only accepts Exception; wrap anything else so a failure + // still leaves the Creating spinner instead of hanging on it. + val exception = it as? Exception ?: RuntimeException(it) + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(exception)) } - } - } - - private fun CoroutineScope.observeEncryptionService( - stateAndDispatch: StateAndDispatch - ) = launch { - encryptionService.enableRecoveryProgressStateFlow.collect { enableRecoveryProgress -> - Timber.tag(loggerTagSetup.value).d("New enableRecoveryProgress: ${enableRecoveryProgress.javaClass.simpleName}") - when (enableRecoveryProgress) { - is EnableRecoveryProgress.Starting, - is EnableRecoveryProgress.CreatingBackup, - is EnableRecoveryProgress.CreatingRecoveryKey, - is EnableRecoveryProgress.BackingUp, - is EnableRecoveryProgress.RoomKeyUploadError -> Unit - is EnableRecoveryProgress.Done -> - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(enableRecoveryProgress.recoveryKey)) - } - } + ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt index 752b5e4851..0e5b60dab7 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt @@ -8,16 +8,40 @@ package io.element.android.features.securebackup.impl.setup +import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements data class SecureBackupSetupState( val isChangeRecoveryKeyUserStory: Boolean, val recoveryKeyViewState: RecoveryKeyViewState, val showSaveConfirmationDialog: Boolean, val setupState: SetupState, + /** False while the well-known is still being fetched; the view shows a spinner until true. */ + val wellknownLoaded: Boolean, + /** Non-null when the well-known requires a user-chosen passphrase; hides the generated-key UI. */ + val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?, + val customEntryStep: CustomEntryStep, + /** User-typed passphrase. Not persisted to saved-state; cleared after a successful submit. */ + val customPassphrase: String, + /** Confirmation field; same lifetime contract as [customPassphrase]. */ + val customPassphraseConfirm: String, + /** True when [customPassphrase] satisfies the minimum-character-count rule from [customRecoveryPassphraseRequirements]. */ + val customPassphraseMeetsMinLength: Boolean, + val customPassphraseMismatch: Boolean, + /** Null while the Entry-step passphrase is empty; otherwise the latest strength reading rendered below the field. */ + val customPassphraseStrength: CustomRecoveryPassphraseStrengthResult?, + /** True when the Entry-step passphrase is valid enough to advance to the Confirm step. */ + val canContinueFromEntry: Boolean, + val canSubmitCustomPassphrase: Boolean, val eventSink: (SecureBackupSetupEvents) -> Unit ) +enum class CustomEntryStep { + Entry, + Confirm, +} + sealed interface SetupState { data object Init : SetupState data object Creating : SetupState diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt index 150aeeddc7..62478e3b87 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt @@ -34,6 +34,9 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine -> state.override { State.KeyCreated(event.key) } } + on { _: Event.UserCancelledCreate, state: MachineState -> + state.override { State.Initial } + } } inState { on { _: Event.UserSavedKey, state: MachineState -> @@ -64,5 +67,8 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine { override val values: Sequence get() = sequenceOf( + aSecureBackupSetupState(wellknownLoaded = false), aSecureBackupSetupState(setupState = SetupState.Init), aSecureBackupSetupState(setupState = SetupState.Creating), aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())), @@ -25,25 +29,141 @@ open class SecureBackupSetupStateProvider : PreviewParameterProvider Unit, modifier: Modifier = Modifier, ) { + // Custom flow auto-skips the "save your key" screen: finish once the SDK accepted it. + LaunchedEffect(state.setupState, state.isCustomEntry()) { + if (state.isCustomEntry() && state.setupState is SetupState.CreatedAndSaved) { + onSuccess() + } + } FlowStepPage( modifier = modifier, - onBackClick = onBackClick.takeIf { state.canGoBack() }, + onBackClick = backClickHandler(state, onBackClick), title = title(state), subTitle = subtitle(state), iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), - buttons = { Buttons(state, onFinish = onSuccess) }, + buttons = { Buttons(state, onFinish = onSuccess, onCancel = onBackClick) }, ) { Content(state = state) } @@ -78,15 +120,45 @@ private fun SecureBackupSetupState.canGoBack(): Boolean { return recoveryKeyViewState.formattedRecoveryKey == null } +private fun SecureBackupSetupState.isCustomEntry(): Boolean = + customRecoveryPassphraseRequirements != null + +private fun backClickHandler( + state: SecureBackupSetupState, + onBackClick: () -> Unit, +): (() -> Unit)? { + if (!state.canGoBack()) return null + if (state.isCustomEntry()) { + // Back while the SDK call is in flight aborts it and returns to the Confirm step + // (typed input preserved) rather than silently flipping the step under the spinner. + if (state.setupState is SetupState.Creating) { + return { state.eventSink.invoke(SecureBackupSetupEvents.CancelCustomPassphraseSubmit) } + } + if (state.customEntryStep == CustomEntryStep.Confirm) { + // In the custom flow, backing out of Confirm returns to Entry (preserve typed input). + return { state.eventSink.invoke(SecureBackupSetupEvents.BackToCustomEntry) } + } + } + return onBackClick +} + @Composable private fun title(state: SecureBackupSetupState): String { + // Hide the heading until the well-known resolves, so it can't flip copy under the user. + if (!state.wellknownLoaded) return "" + // Custom flow has no "save your key" step — use per-step custom titles throughout. + if (state.isCustomEntry()) { + return when (state.customEntryStep) { + CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_title) + CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_title) + } + } return when (state.setupState) { SetupState.Init, SetupState.Creating, - is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) { - stringResource(id = R.string.screen_recovery_key_change_title) - } else { - stringResource(id = R.string.screen_recovery_key_setup_title) + is SetupState.Error -> when { + state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_title) + else -> stringResource(id = R.string.screen_recovery_key_setup_title) } is SetupState.Created, is SetupState.CreatedAndSaved -> @@ -95,14 +167,20 @@ private fun title(state: SecureBackupSetupState): String { } @Composable -private fun subtitle(state: SecureBackupSetupState): String { +private fun subtitle(state: SecureBackupSetupState): String? { + if (!state.wellknownLoaded) return null + if (state.isCustomEntry()) { + return when (state.customEntryStep) { + CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_description) + CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_description) + } + } return when (state.setupState) { SetupState.Init, SetupState.Creating, - is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) { - stringResource(id = R.string.screen_recovery_key_change_description) - } else { - stringResource(id = R.string.screen_recovery_key_setup_description) + is SetupState.Error -> when { + state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_description) + else -> stringResource(id = R.string.screen_recovery_key_setup_description) } is SetupState.Created, is SetupState.CreatedAndSaved -> @@ -114,6 +192,25 @@ private fun subtitle(state: SecureBackupSetupState): String { private fun Content( state: SecureBackupSetupState, ) { + // Spinner until the well-known resolves, so neither flow renders before we know which applies. + if (!state.wellknownLoaded && state.setupState == SetupState.Init) { + LoadingPlaceholder() + return + } + if (state.isCustomEntry()) { + when (state.setupState) { + SetupState.Init, + is SetupState.Error -> when (state.customEntryStep) { + CustomEntryStep.Entry -> CustomPassphraseEntry(state = state) + CustomEntryStep.Confirm -> CustomPassphraseConfirm(state = state) + } + // Hold the spinner through the auto-skip so the SDK base58 key is never shown/shared. + SetupState.Creating, + is SetupState.Created, + is SetupState.CreatedAndSaved -> LoadingPlaceholder() + } + return + } val context = LocalContext.current val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard) @@ -144,10 +241,269 @@ private fun Content( ) } +@Composable +private fun LoadingPlaceholder() { + val description = stringResource(id = R.string.a11y_recovery_key_loading_specs) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 52.dp) + .semantics { contentDescription = description }, + contentAlignment = Alignment.Center, + ) { + // CircularProgressIndicator applies progressSemantics() internally — no need to add it here. + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } +} + +@Composable +private fun CustomPassphraseEntry(state: SecureBackupSetupState) { + val specs = state.customRecoveryPassphraseRequirements ?: return + var passphraseVisible by rememberSaveable { mutableStateOf(false) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + TextField( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.customRecoveryPassphrase) + .semantics { contentType = ContentType.NewPassword }, + value = state.customPassphrase, + onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(it)) }, + label = stringResource(id = CommonStrings.common_recovery_key), + singleLine = true, + visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + PasswordVisibilityToggle( + visible = passphraseVisible, + onToggle = { passphraseVisible = !passphraseVisible }, + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + if (state.canContinueFromEntry) { + state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + } + }, + ), + supportingText = pluralStringResource( + id = R.plurals.screen_recovery_key_custom_requirement_length, + count = specs.minCharacterCount, + specs.minCharacterCount, + ), + ) + // Always render the strength row in the Entry step (matches iOS): an empty field shows the + // "Strength" base label with an empty bar; typing flips it to the per-level label + bar. + Spacer(modifier = Modifier.height(8.dp)) + PassphraseStrengthIndicator( + result = state.customPassphraseStrength, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun PassphraseStrengthIndicator( + result: CustomRecoveryPassphraseStrengthResult?, + modifier: Modifier = Modifier, +) { + val label = stringResource( + id = result?.strength?.labelRes() ?: R.string.screen_recovery_key_custom_strength_base, + ) + val hint = result?.strength?.hintRes()?.let { stringResource(id = it) } + val score = result?.score ?: 0f + // Mirror iOS: a zero score (empty field or the Garbage tier) stays neutral; otherwise the + // label and bar share a single colour interpolated along the red→orange→yellow→green gradient. + val color = if (score <= 0f) ElementTheme.colors.textSecondary else passphraseStrengthColor(score) + val announcement = stringResource(id = R.string.a11y_recovery_key_custom_strength_announcement, label) + .let { if (hint != null) "$it. $hint" else it } + Column( + modifier = modifier.semantics(mergeDescendants = true) { + contentDescription = announcement + }, + ) { + Text( + text = label, + color = color, + style = ElementTheme.typography.fontBodySmMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { score }, + modifier = Modifier.fillMaxWidth(), + color = color, + ) + if (hint != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = hint, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } +} + +private fun CustomRecoveryPassphraseStrength.labelRes(): Int = when (this) { + CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_garbage + CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_weak + CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_moderate + CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_okay + CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_strong + CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_very_strong + CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_ultra_strong + CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_mega +} + +private fun CustomRecoveryPassphraseStrength.hintRes(): Int = when (this) { + CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_hint_garbage + CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_hint_weak + CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_hint_moderate + CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_hint_okay + CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_hint_strong + CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_hint_very_strong + CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_hint_ultra_strong + CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_hint_mega +} + +// Stops mirror iOS PasswordStrengthBar.gradient: Compound red900 / lime700 with the same vivid +// orange & yellow midpoints. Intentionally fixed (not light/dark semantic) so the gradient reads +// identically to iOS in both themes. +private val passphraseStrengthGradientStops = listOf( + 0.25f to Color(0xFFD51928), // Compound red900 + 0.5f to Color(0xFFFF9500), // orange + 0.75f to Color(0xFFFFCC00), // yellow + 1.0f to Color(0xFF54C424), // Compound lime700 +) + +/** Interpolates the strength bar colour along [passphraseStrengthGradientStops] for a 0f..1f score. */ +private fun passphraseStrengthColor(score: Float): Color { + val clamped = score.coerceIn(0f, 1f) + var previousLocation = 0f + var previousColor = passphraseStrengthGradientStops.first().second + for ((location, color) in passphraseStrengthGradientStops) { + if (clamped <= location) { + val span = location - previousLocation + if (span <= 0f) return color + return lerp(previousColor, color, ((clamped - previousLocation) / span).coerceIn(0f, 1f)) + } + previousLocation = location + previousColor = color + } + return previousColor +} + +@Composable +private fun CustomPassphraseConfirm(state: SecureBackupSetupState) { + var passphraseVisible by rememberSaveable { mutableStateOf(false) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + TextField( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.customRecoveryPassphraseConfirm) + .semantics { contentType = ContentType.NewPassword }, + value = state.customPassphraseConfirm, + onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(it)) }, + label = stringResource(id = CommonStrings.common_recovery_key), + singleLine = true, + visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + PasswordVisibilityToggle( + visible = passphraseVisible, + onToggle = { passphraseVisible = !passphraseVisible }, + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + if (state.canSubmitCustomPassphrase) { + state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + } + }, + ), + validity = if (state.customPassphraseMismatch) TextFieldValidity.Invalid else TextFieldValidity.None, + supportingText = if (state.customPassphraseMismatch) { + stringResource(id = R.string.screen_recovery_key_custom_mismatch) + } else { + null + }, + ) + } +} + @Composable private fun ColumnScope.Buttons( state: SecureBackupSetupState, onFinish: () -> Unit, + onCancel: () -> Unit, +) { + // No buttons until the well-known resolves; Content shows a spinner meanwhile. + if (!state.wellknownLoaded && state.setupState == SetupState.Init) return + if (state.isCustomEntry()) { + CustomButtons(state = state, onCancel = onCancel) + } else { + AutoGenButtons(state = state, onFinish = onFinish) + } +} + +@Composable +private fun ColumnScope.CustomButtons( + state: SecureBackupSetupState, + onCancel: () -> Unit, +) { + // No save/share controls in the custom flow — they'd leak the base58 key during Created. + when (state.setupState) { + SetupState.Creating, + is SetupState.Created, + is SetupState.CreatedAndSaved -> return + SetupState.Init, + is SetupState.Error -> Unit + } + when (state.customEntryStep) { + CustomEntryStep.Entry -> { + Button( + text = stringResource(id = CommonStrings.action_continue), + enabled = state.canContinueFromEntry, + modifier = Modifier.fillMaxWidth(), + onClick = { state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) }, + ) + TextButton( + text = stringResource(id = CommonStrings.action_cancel), + modifier = Modifier.fillMaxWidth(), + onClick = onCancel, + ) + } + CustomEntryStep.Confirm -> { + Button( + text = stringResource(id = R.string.screen_recovery_key_custom_finish_action), + enabled = state.canSubmitCustomPassphrase, + modifier = Modifier.fillMaxWidth(), + onClick = { state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) }, + ) + } + } +} + +@Composable +private fun ColumnScope.AutoGenButtons( + state: SecureBackupSetupState, + onFinish: () -> Unit, ) { val context = LocalContext.current val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action) diff --git a/features/securebackup/impl/src/main/res/values/temporary.xml b/features/securebackup/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..3edbd84d73 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values/temporary.xml @@ -0,0 +1,37 @@ + + + Enter a custom recovery key + Choose a recovery key that you can memorize. + Confirm your recovery key + Enter your recovery key again. + The recovery key you entered doesn\'t match + + Minimum %1$d character + Minimum %1$d characters + + Finish setup + Strength + Garbage + Weak + Moderate + Okay + Strong + Very strong + Super strong + Ideal + Cracking would take roughly milliseconds. + Cracking would take roughly minutes to months. + Cracking would take roughly years. + Cracking would take roughly hundreds of years. + Cracking would take roughly billions of years. + Cracking would take roughly trillions of years. + Cracking would take roughly quadrillions of years. + Cracking would take roughly quintillions of years. + Loading recovery key requirements + Passphrase strength: %1$s + diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt index a3cf920d82..6ca716bcd6 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt @@ -6,23 +6,37 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + package io.element.android.features.securebackup.impl.setup import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrength +import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState -import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress +import io.element.android.features.wellknown.test.FakeSessionWellknownRetriever +import io.element.android.features.wellknown.test.aCustomRecoveryPassphraseRequirements +import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.test.A_RECOVERY_KEY import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.wellknown.api.SessionWellknownRetriever +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +@Suppress("LargeClass") class SecureBackupSetupPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -33,11 +47,16 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse() - assertThat(initialState.setupState).isEqualTo(SetupState.Init) - assertThat(initialState.showSaveConfirmationDialog).isFalse() - assertThat(initialState.recoveryKeyViewState).isEqualTo( + val preFetch = awaitItem() + assertThat(preFetch.wellknownLoaded).isFalse() + assertThat(preFetch.setupState).isEqualTo(SetupState.Init) + + val loaded = awaitItem() + assertThat(loaded.wellknownLoaded).isTrue() + assertThat(loaded.isChangeRecoveryKeyUserStory).isFalse() + assertThat(loaded.setupState).isEqualTo(SetupState.Init) + assertThat(loaded.showSaveConfirmationDialog).isFalse() + assertThat(loaded.recoveryKeyViewState).isEqualTo( RecoveryKeyViewState( recoveryKeyUserStory = RecoveryKeyUserStory.Setup, formattedRecoveryKey = null, @@ -50,14 +69,17 @@ class SecureBackupSetupPresenterTest { @Test fun `present - create recovery key and save it`() = runTest { - val encryptionService = FakeEncryptionService() + val encryptionService = FakeEncryptionService( + enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) }, + ) val presenter = createSecureBackupSetupPresenter( encryptionService = encryptionService ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() + awaitItem() // pre-fetch + val initialState = awaitItem() // post-fetch, wellknownLoaded=true initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) val creatingState = awaitItem() assertThat(creatingState.setupState).isEqualTo(SetupState.Creating) @@ -69,7 +91,6 @@ class SecureBackupSetupPresenterTest { inProgress = true, ) ) - encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY)) val createdState = awaitItem() assertThat(createdState.setupState).isEqualTo(SetupState.Created(A_RECOVERY_KEY)) assertThat(createdState.recoveryKeyViewState).isEqualTo( @@ -100,7 +121,9 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + awaitItem() // pre-fetch val initialState = awaitItem() + assertThat(initialState.wellknownLoaded).isTrue() assertThat(initialState.isChangeRecoveryKeyUserStory).isTrue() assertThat(initialState.setupState).isEqualTo(SetupState.Init) assertThat(initialState.recoveryKeyViewState).isEqualTo( @@ -117,7 +140,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, @@ -126,6 +149,7 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + awaitItem() // pre-fetch val initialState = awaitItem() assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse() assertThat(initialState.setupState).isEqualTo(SetupState.Init) @@ -152,6 +176,7 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { + awaitItem() // pre-fetch val initialState = awaitItem() initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) val creatingState = awaitItem() @@ -186,16 +211,727 @@ class SecureBackupSetupPresenterTest { } } + @Test + fun `present - wellknownLoaded flips false to true exactly once after fetch resolves`() = runTest { + val presenter = createSecureBackupSetupPresenter( + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val preFetch = awaitItem() + assertThat(preFetch.wellknownLoaded).isFalse() + assertThat(preFetch.customRecoveryPassphraseRequirements).isNull() + + val loaded = awaitItem() + assertThat(loaded.wellknownLoaded).isTrue() + assertThat(loaded.customRecoveryPassphraseRequirements).isNotNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec absent leaves customRecoveryPassphraseRequirements null and uses generated-key path`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = null)) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val preFetch = awaitItem() + assertThat(preFetch.wellknownLoaded).isFalse() + val loaded = awaitItem() + assertThat(loaded.wellknownLoaded).isTrue() + assertThat(loaded.customRecoveryPassphraseRequirements).isNull() + assertThat(loaded.canSubmitCustomPassphrase).isFalse() + loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) + awaitItem() // creating + advanceUntilIdle() + enableRecoveryLambda.assertions().isCalledOnce() + .with(value(false), value(null)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec present surfaces validation errors and blocks continue`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // initial, pre-fetch + val withSpec = awaitItem() + assertThat(withSpec.customRecoveryPassphraseRequirements).isNotNull() + assertThat(withSpec.customEntryStep).isEqualTo(CustomEntryStep.Entry) + assertThat(withSpec.canContinueFromEntry).isFalse() + assertThat(withSpec.canSubmitCustomPassphrase).isFalse() + + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc")) + val afterShortInput = awaitItem() + assertThat(afterShortInput.customPassphrase).isEqualTo("abc") + assertThat(afterShortInput.customPassphraseMeetsMinLength).isFalse() + assertThat(afterShortInput.canContinueFromEntry).isFalse() + + // Continue is a no-op when entry is invalid: step stays Entry. + afterShortInput.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + expectNoEvents() + enableRecoveryLambda.assertions().isNeverCalled() + } + } + + @Test + fun `present - custom spec strength is null while empty and delegates to the enterprise estimator once user types`() = runTest { + // The estimation algorithm lives in the enterprise module; here we only verify the presenter + // suppresses the indicator while the field is empty and otherwise forwards the service result. + val presenter = createSecureBackupSetupPresenter( + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + enterpriseService = FakeEnterpriseService( + isCustomRecoveryPassphraseEnabledResult = true, + estimateCustomRecoveryPassphraseStrengthResult = { passphrase -> + if (passphrase.length > 5) { + CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.9f) + } else { + CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Weak, score = 0.1f) + } + }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val withSpec = awaitItem() + // Field is empty → indicator suppressed (the estimator is never consulted). + assertThat(withSpec.customPassphraseStrength).isNull() + + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc")) + val afterShort = awaitItem() + assertThat(afterShort.customPassphraseStrength?.strength) + .isEqualTo(CustomRecoveryPassphraseStrength.Weak) + + afterShort.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("Abcdefg1!@#xyz")) + val afterStrong = awaitItem() + assertThat(afterStrong.customPassphraseStrength?.strength) + .isEqualTo(CustomRecoveryPassphraseStrength.Strong) + + // Clearing the field brings the indicator back to null. + afterStrong.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("")) + val cleared = awaitItem() + assertThat(cleared.customPassphraseStrength).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec ContinueCustomPassphrase advances Entry to Confirm when valid`() = runTest { + val presenter = createSecureBackupSetupPresenter( + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // initial + val withSpec = awaitItem() + assertThat(withSpec.customEntryStep).isEqualTo(CustomEntryStep.Entry) + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + val typed = awaitItem() + assertThat(typed.canContinueFromEntry).isTrue() + + typed.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val advanced = awaitItem() + assertThat(advanced.customEntryStep).isEqualTo(CustomEntryStep.Confirm) + assertThat(advanced.customPassphrase).isEqualTo(valid) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec BackToCustomEntry returns to Entry preserving both fields`() = runTest { + val presenter = createSecureBackupSetupPresenter( + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // initial + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + awaitItem() + withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm) + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + awaitItem() + + onConfirm.eventSink.invoke(SecureBackupSetupEvents.BackToCustomEntry) + val backOnEntry = awaitItem() + assertThat(backOnEntry.customEntryStep).isEqualTo(CustomEntryStep.Entry) + assertThat(backOnEntry.customPassphrase).isEqualTo(valid) + assertThat(backOnEntry.customPassphraseConfirm).isEqualTo(valid) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec SubmitCustomPassphrase from Entry step is a no-op`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // initial + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + val typed = awaitItem() + // Both fields contain matching content via a stale path: simulate by also setting confirm + // while still on the Entry step (e.g., previously typed). Submit must still be gated. + typed.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val bothTyped = awaitItem() + assertThat(bothTyped.customEntryStep).isEqualTo(CustomEntryStep.Entry) + assertThat(bothTyped.canSubmitCustomPassphrase).isFalse() + + bothTyped.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + expectNoEvents() + enableRecoveryLambda.assertions().isNeverCalled() + } + } + + @Test + fun `present - custom spec present submit with valid input forwards passphrase to SDK`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // initial + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + val afterKey = awaitItem() + afterKey.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm) + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val ready = awaitItem() + assertThat(ready.canSubmitCustomPassphrase).isTrue() + + ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + awaitItem() // creating + advanceUntilIdle() + enableRecoveryLambda.assertions().isCalledOnce() + .with(value(false), value(valid)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec error retains typed passphrase and Confirm step across DismissDialog`() = runTest { + val encryptionService = FakeEncryptionService( + enableRecoveryLambda = { _, _ -> Result.failure(IllegalStateException("boom")) }, + ) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + awaitItem() + withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val ready = awaitItem() + assertThat(ready.canSubmitCustomPassphrase).isTrue() + + ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + awaitItem() // creating + val errored = awaitItem() + assertThat(errored.setupState).isInstanceOf(SetupState.Error::class.java) + + errored.eventSink.invoke(SecureBackupSetupEvents.DismissDialog) + val afterDismiss = awaitItem() + assertThat(afterDismiss.setupState).isEqualTo(SetupState.Init) + assertThat(afterDismiss.customPassphrase).isEqualTo(valid) + assertThat(afterDismiss.customPassphraseConfirm).isEqualTo(valid) + assertThat(afterDismiss.customEntryStep).isEqualTo(CustomEntryStep.Confirm) + assertThat(afterDismiss.canSubmitCustomPassphrase).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec submit success auto-advances to CreatedAndSaved without manual save`() = runTest { + val encryptionService = FakeEncryptionService( + enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) }, + ) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + awaitItem() + withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val ready = awaitItem() + assertThat(ready.canSubmitCustomPassphrase).isTrue() + + ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + awaitItem() // creating + advanceUntilIdle() + + // The presenter's auto-skip LaunchedEffect should have advanced state machine past + // Created without requiring a manual RecoveryKeyHasBeenSaved event. The terminal + // state observed is CreatedAndSaved. + val finalState = expectMostRecentItem() + assertThat(finalState.setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java) + // The SDK-derived base58 key must not leak through the recoveryKeyViewState in + // the custom-passphrase flow. + assertThat(finalState.recoveryKeyViewState.formattedRecoveryKey).isNull() + // The state machine's KeyCreatedAndSaved variant carries a key too — confirm + // the presenter scrubbed it before dispatching SdkHasCreatedKey, so observers + // of setupState don't see the SDK-generated base58 key either. + assertThat(finalState.setupState).isEqualTo(SetupState.CreatedAndSaved(formattedRecoveryKey = "")) + assertThat(finalState.setupState.recoveryKey()).isEqualTo("") + // Typed passphrase is cleared once the SDK call returns successfully. + assertThat(finalState.customPassphrase).isEmpty() + assertThat(finalState.customPassphraseConfirm).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - enterprise feature disabled suppresses spec and forces auto-gen path`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + enterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = false), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val loaded = awaitItem() + assertThat(loaded.wellknownLoaded).isTrue() + // Even though the homeserver advertised a spec, the enterprise gate suppresses it. + assertThat(loaded.customRecoveryPassphraseRequirements).isNull() + assertThat(loaded.canSubmitCustomPassphrase).isFalse() + loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) + awaitItem() // creating + advanceUntilIdle() + enableRecoveryLambda.assertions().isCalledOnce() + .with(value(false), value(null)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - well-known fetch failure falls back to generated-key path`() = runTest { + val presenter = createSecureBackupSetupPresenter( + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Error(IllegalStateException("boom")) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val preFetch = awaitItem() + assertThat(preFetch.wellknownLoaded).isFalse() + val loaded = awaitItem() + assertThat(loaded.wellknownLoaded).isTrue() + assertThat(loaded.customRecoveryPassphraseRequirements).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change + custom spec absent uses resetRecoveryKey path`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = true, + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = null)) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val loaded = awaitItem() + assertThat(loaded.customRecoveryPassphraseRequirements).isNull() + loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) + awaitItem() // creating + val createdState = awaitItem() + assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY)) + enableRecoveryLambda.assertions().isNeverCalled() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change + custom spec present surfaces validation errors and blocks continue`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = true, + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // initial, pre-fetch + val withSpec = awaitItem() + assertThat(withSpec.customRecoveryPassphraseRequirements).isNotNull() + assertThat(withSpec.canContinueFromEntry).isFalse() + + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc")) + val afterShortInput = awaitItem() + assertThat(afterShortInput.customPassphrase).isEqualTo("abc") + assertThat(afterShortInput.customPassphraseMeetsMinLength).isFalse() + assertThat(afterShortInput.canContinueFromEntry).isFalse() + + afterShortInput.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + expectNoEvents() + enableRecoveryLambda.assertions().isNeverCalled() + } + } + + @Test + fun `present - change + custom spec present submit with valid input forwards passphrase to enableRecovery`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = true, + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // initial + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + val afterKey = awaitItem() + afterKey.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val ready = awaitItem() + assertThat(ready.canSubmitCustomPassphrase).isTrue() + + ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + awaitItem() // creating + advanceUntilIdle() + enableRecoveryLambda.assertions().isCalledOnce() + .with(value(false), value(valid)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change + well-known fetch failure falls back to resetRecoveryKey path`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = true, + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Error(IllegalStateException("boom")) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val loaded = awaitItem() + assertThat(loaded.customRecoveryPassphraseRequirements).isNull() + loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) + awaitItem() // creating + val createdState = awaitItem() + assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY)) + enableRecoveryLambda.assertions().isNeverCalled() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec Confirm mismatch clears once user edits to match`() = runTest { + val presenter = createSecureBackupSetupPresenter( + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + awaitItem() + withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + assertThat(onConfirm.customPassphraseMismatch).isFalse() + + // Typing a different value flips mismatch=true and blocks submit. + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm("differen")) + val mismatched = awaitItem() + assertThat(mismatched.customPassphraseMismatch).isTrue() + assertThat(mismatched.canSubmitCustomPassphrase).isFalse() + + // Editing the confirm field to match clears the mismatch and re-enables submit. + mismatched.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val matched = awaitItem() + assertThat(matched.customPassphraseMismatch).isFalse() + assertThat(matched.canSubmitCustomPassphrase).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec rapid double-submit invokes the SDK only once`() = runTest { + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + awaitItem() + withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val ready = awaitItem() + assertThat(ready.canSubmitCustomPassphrase).isTrue() + + // Two synchronous submits dispatched from the same snapshot (e.g., button + IME-Done + // racing). The presenter's in-flight guard must drop the second one before it + // launches a second enableRecovery coroutine. + ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + advanceUntilIdle() + enableRecoveryLambda.assertions().isCalledOnce() + .with(value(false), value(valid)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change + custom spec submit routes to enableRecovery and never resetRecoveryKey`() = runTest { + // Guards the custom-passphrase Change path against silently dropping the passphrase: + // the user's passphrase reaches the SDK only via enableRecovery(passphrase); the + // passphrase-less resetRecoveryKey() path must never be taken here. A true end-to-end + // check that the 4S key actually rotates needs the real SDK + homeserver and lives + // outside this JVM suite. + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val resetRecoveryKeyLambda = lambdaRecorder> { Result.success(FakeEncryptionService.FAKE_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService( + enableRecoveryLambda = enableRecoveryLambda, + resetRecoveryKeyLambda = resetRecoveryKeyLambda, + ) + val presenter = createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = true, + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + awaitItem() + withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val ready = awaitItem() + assertThat(ready.canSubmitCustomPassphrase).isTrue() + + ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + awaitItem() // creating + advanceUntilIdle() + enableRecoveryLambda.assertions().isCalledOnce() + .with(value(false), value(valid)) + resetRecoveryKeyLambda.assertions().isNeverCalled() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - custom spec cancel while creating aborts the SDK call and returns to Confirm with input preserved`() = runTest { + // simulateLongTask suspends on delay(1) before invoking the lambda, so cancelling before + // advancing virtual time guarantees the SDK call never runs. + val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } + val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + awaitItem() + withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) + val ready = awaitItem() + assertThat(ready.canSubmitCustomPassphrase).isTrue() + + ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) + val creating = awaitItem() + assertThat(creating.setupState).isEqualTo(SetupState.Creating) + + creating.eventSink.invoke(SecureBackupSetupEvents.CancelCustomPassphraseSubmit) + val cancelled = awaitItem() + assertThat(cancelled.setupState).isEqualTo(SetupState.Init) + assertThat(cancelled.customEntryStep).isEqualTo(CustomEntryStep.Confirm) + assertThat(cancelled.customPassphrase).isEqualTo(valid) + assertThat(cancelled.customPassphraseConfirm).isEqualTo(valid) + assertThat(cancelled.canSubmitCustomPassphrase).isTrue() + + // The cancelled coroutine never reached the SDK, and no zombie success/error followed. + advanceUntilIdle() + enableRecoveryLambda.assertions().isNeverCalled() + expectNoEvents() + } + } + + @Test + fun `present - custom spec strength indicator is suppressed on the Confirm step`() = runTest { + val presenter = createSecureBackupSetupPresenter( + sessionWellknownRetriever = FakeSessionWellknownRetriever { + WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) + }, + enterpriseService = FakeEnterpriseService( + isCustomRecoveryPassphraseEnabledResult = true, + estimateCustomRecoveryPassphraseStrengthResult = { + CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.9f) + }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem() // pre-fetch + val withSpec = awaitItem() + + val valid = "Ab12!@cd" + withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) + val onEntry = awaitItem() + // Entry step: the estimator result is surfaced. + assertThat(onEntry.customPassphraseStrength?.strength).isEqualTo(CustomRecoveryPassphraseStrength.Strong) + + onEntry.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) + val onConfirm = awaitItem() + // Confirm step: indicator is not rendered, so the presenter stops computing it. + assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm) + assertThat(onConfirm.customPassphraseStrength).isNull() + cancelAndIgnoreRemainingEvents() + } + } + private fun createSecureBackupSetupPresenter( isChangeRecoveryKeyUserStory: Boolean = false, encryptionService: EncryptionService = FakeEncryptionService( - enableRecoveryLambda = { Result.success(Unit) }, + enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) }, ), + sessionWellknownRetriever: SessionWellknownRetriever = FakeSessionWellknownRetriever(), + enterpriseService: EnterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = true), ): SecureBackupSetupPresenter { return SecureBackupSetupPresenter( isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = encryptionService, + sessionWellknownRetriever = sessionWellknownRetriever, + enterpriseService = enterpriseService, ) } } diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 4b3a0d6ffa..593560936e 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -29,6 +29,8 @@ object TestTags { * Verification screen. */ val recoveryKey = TestTag("verification-recovery_key") + val customRecoveryPassphrase = TestTag("verification-custom_recovery_passphrase") + val customRecoveryPassphraseConfirm = TestTag("verification-custom_recovery_passphrase_confirm") /** * Sign out screen. From 3fc48787b80393cab46319eaf05cc4a277d72238 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 2 Jun 2026 15:01:17 -0700 Subject: [PATCH 06/23] Add missing PasswordVisibilityToggle extraction areas --- .../logout/impl/AccountDeactivationView.kt | 14 +++++--------- .../impl/setup/views/RecoveryKeyView.kt | 16 +++++----------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt index 352efdfb92..c942d2df72 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt @@ -8,7 +8,6 @@ package io.element.android.features.logout.impl -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.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.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextField @@ -280,14 +280,10 @@ private fun Content( placeholder = stringResource(CommonStrings.common_password), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - val image = - if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff() - val description = - if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password) - - Box(modifier = Modifier.clickable { passwordVisible = !passwordVisible }) { - Icon(imageVector = image, description) - } + PasswordVisibilityToggle( + visible = passwordVisible, + onToggle = { passwordVisible = !passwordVisible }, + ) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt index 198891473c..8055709f20 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt @@ -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) }, + ) }, ) } From 8722305e124249e6769fa37902f76feed55f5f37 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 4 Jun 2026 09:41:55 -0700 Subject: [PATCH 07/23] Update strings for ios parity --- .../impl/setup/SecureBackupSetupView.kt | 52 +++++++++---------- .../impl/src/main/res/values/temporary.xml | 52 +++++++++---------- 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt index 2d086c8cf5..0f8bf7d043 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentType @@ -149,8 +148,8 @@ private fun title(state: SecureBackupSetupState): String { // Custom flow has no "save your key" step — use per-step custom titles throughout. if (state.isCustomEntry()) { return when (state.customEntryStep) { - CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_title) - CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_title) + CustomEntryStep.Entry -> stringResource(id = R.string.pro_screen_recovery_key_mode_input_title) + CustomEntryStep.Confirm -> stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_title) } } return when (state.setupState) { @@ -171,8 +170,8 @@ private fun subtitle(state: SecureBackupSetupState): String? { if (!state.wellknownLoaded) return null if (state.isCustomEntry()) { return when (state.customEntryStep) { - CustomEntryStep.Entry -> stringResource(id = R.string.screen_recovery_key_custom_description) - CustomEntryStep.Confirm -> stringResource(id = R.string.screen_recovery_key_custom_confirm_description) + CustomEntryStep.Entry -> stringResource(id = R.string.pro_screen_recovery_key_mode_input_description) + CustomEntryStep.Confirm -> stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_description) } } return when (state.setupState) { @@ -295,9 +294,8 @@ private fun CustomPassphraseEntry(state: SecureBackupSetupState) { } }, ), - supportingText = pluralStringResource( - id = R.plurals.screen_recovery_key_custom_requirement_length, - count = specs.minCharacterCount, + supportingText = stringResource( + id = R.string.pro_screen_recovery_key_mode_input_passphrase_field_footer, specs.minCharacterCount, ), ) @@ -317,7 +315,7 @@ private fun PassphraseStrengthIndicator( modifier: Modifier = Modifier, ) { val label = stringResource( - id = result?.strength?.labelRes() ?: R.string.screen_recovery_key_custom_strength_base, + id = result?.strength?.labelRes() ?: R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_base, ) val hint = result?.strength?.hintRes()?.let { stringResource(id = it) } val score = result?.score ?: 0f @@ -354,25 +352,25 @@ private fun PassphraseStrengthIndicator( } private fun CustomRecoveryPassphraseStrength.labelRes(): Int = when (this) { - CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_garbage - CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_weak - CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_moderate - CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_okay - CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_strong - CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_very_strong - CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_ultra_strong - CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_mega + CustomRecoveryPassphraseStrength.Garbage -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_garbage + CustomRecoveryPassphraseStrength.Weak -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_weak + CustomRecoveryPassphraseStrength.Moderate -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_moderate + CustomRecoveryPassphraseStrength.Okay -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_okay + CustomRecoveryPassphraseStrength.Strong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_strong + CustomRecoveryPassphraseStrength.VeryStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_very_strong + CustomRecoveryPassphraseStrength.UltraStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_ultra_strong + CustomRecoveryPassphraseStrength.Mega -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_mega } private fun CustomRecoveryPassphraseStrength.hintRes(): Int = when (this) { - CustomRecoveryPassphraseStrength.Garbage -> R.string.screen_recovery_key_custom_strength_hint_garbage - CustomRecoveryPassphraseStrength.Weak -> R.string.screen_recovery_key_custom_strength_hint_weak - CustomRecoveryPassphraseStrength.Moderate -> R.string.screen_recovery_key_custom_strength_hint_moderate - CustomRecoveryPassphraseStrength.Okay -> R.string.screen_recovery_key_custom_strength_hint_okay - CustomRecoveryPassphraseStrength.Strong -> R.string.screen_recovery_key_custom_strength_hint_strong - CustomRecoveryPassphraseStrength.VeryStrong -> R.string.screen_recovery_key_custom_strength_hint_very_strong - CustomRecoveryPassphraseStrength.UltraStrong -> R.string.screen_recovery_key_custom_strength_hint_ultra_strong - CustomRecoveryPassphraseStrength.Mega -> R.string.screen_recovery_key_custom_strength_hint_mega + CustomRecoveryPassphraseStrength.Garbage -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_garbage + CustomRecoveryPassphraseStrength.Weak -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_weak + CustomRecoveryPassphraseStrength.Moderate -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_moderate + CustomRecoveryPassphraseStrength.Okay -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_okay + CustomRecoveryPassphraseStrength.Strong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_strong + CustomRecoveryPassphraseStrength.VeryStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_very_strong + CustomRecoveryPassphraseStrength.UltraStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_ultra_strong + CustomRecoveryPassphraseStrength.Mega -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_mega } // Stops mirror iOS PasswordStrengthBar.gradient: Compound red900 / lime700 with the same vivid @@ -439,7 +437,7 @@ private fun CustomPassphraseConfirm(state: SecureBackupSetupState) { ), validity = if (state.customPassphraseMismatch) TextFieldValidity.Invalid else TextFieldValidity.None, supportingText = if (state.customPassphraseMismatch) { - stringResource(id = R.string.screen_recovery_key_custom_mismatch) + stringResource(id = R.string.pro_screen_recovery_key_mode_custom_mismatch) } else { null }, @@ -491,7 +489,7 @@ private fun ColumnScope.CustomButtons( } CustomEntryStep.Confirm -> { Button( - text = stringResource(id = R.string.screen_recovery_key_custom_finish_action), + text = stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_finish_button), enabled = state.canSubmitCustomPassphrase, modifier = Modifier.fillMaxWidth(), onClick = { state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) }, diff --git a/features/securebackup/impl/src/main/res/values/temporary.xml b/features/securebackup/impl/src/main/res/values/temporary.xml index 3edbd84d73..c573a4c6e3 100644 --- a/features/securebackup/impl/src/main/res/values/temporary.xml +++ b/features/securebackup/impl/src/main/res/values/temporary.xml @@ -5,33 +5,31 @@ ~ Please see LICENSE files in the repository root for full details. --> - Enter a custom recovery key - Choose a recovery key that you can memorize. - Confirm your recovery key - Enter your recovery key again. - The recovery key you entered doesn\'t match - - Minimum %1$d character - Minimum %1$d characters - - Finish setup - Strength - Garbage - Weak - Moderate - Okay - Strong - Very strong - Super strong - Ideal - Cracking would take roughly milliseconds. - Cracking would take roughly minutes to months. - Cracking would take roughly years. - Cracking would take roughly hundreds of years. - Cracking would take roughly billions of years. - Cracking would take roughly trillions of years. - Cracking would take roughly quadrillions of years. - Cracking would take roughly quintillions of years. + Enter a recovery key + Choose a recovery key that you can memorize. + Confirm your recovery key + Enter your recovery key again. + Minimum %1$s characters + Finish setup + Strength + Garbage + Weak + Moderate + Okay + Strong + Very Strong + Super Strong + Ideal + Cracking would take roughly milliseconds. + Cracking would take roughly minutes to months. + Cracking would take roughly years. + Cracking would take roughly hundreds of years. + Cracking would take roughly billions of years. + Cracking would take roughly trillions of years. + Cracking would take roughly quadrillions of years. + Cracking would take roughly quintillions of years. + + The recovery key you entered doesn\'t match Loading recovery key requirements Passphrase strength: %1$s From 1f84bef601b6213005156462451a3388ad2bb5ec Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 8 Jun 2026 15:35:30 -0700 Subject: [PATCH 08/23] Add SecureBackupSetupEntryPoint extension seam Introduces a FeatureEntryPoint for the recovery-key setup screen so enterprise builds can vend a richer setup node. The FOSS default builds the standard auto-generated-key node; SecureBackupFlowNode now resolves the Setup and Change targets through this seam instead of creating SecureBackupSetupNode directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/SecureBackupSetupEntryPoint.kt | 30 ++++++++++++++++ .../DefaultSecureBackupSetupEntryPoint.kt | 34 +++++++++++++++++++ .../securebackup/impl/SecureBackupFlowNode.kt | 17 ++++++---- .../impl/DefaultSecureBackupEntryPointTest.kt | 1 + 4 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt create mode 100644 features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPoint.kt diff --git a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt new file mode 100644 index 0000000000..ca837d9cf8 --- /dev/null +++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt @@ -0,0 +1,30 @@ +/* + * 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 + +/** + * Entry point for the recovery-key setup screen. + * + * The default (FOSS) implementation builds the standard auto-generated-key setup node. Enterprise + * builds replace this binding to vend a richer setup node (e.g. a homeserver-advertised custom + * recovery passphrase) while falling back to the standard node when no custom spec applies. + */ +interface SecureBackupSetupEntryPoint : FeatureEntryPoint { + data class Inputs(val isChangeRecoveryKeyUserStory: Boolean) : NodeInputs + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: Inputs, + ): Node +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPoint.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPoint.kt new file mode 100644 index 0000000000..29465b1be1 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPoint.kt @@ -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( + buildContext = buildContext, + plugins = listOf( + SecureBackupSetupNode.Inputs( + isChangeRecoveryKeyUserStory = inputs.isChangeRecoveryKeyUserStory, + ) + ), + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt index d9fd8a1785..035e47d1c7 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt @@ -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, + private val secureBackupSetupEntryPoint: SecureBackupSetupEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = when (plugins.filterIsInstance().first().initialElement) { @@ -97,16 +98,18 @@ class SecureBackupFlowNode( createNode(buildContext, listOf(callback)) } NavTarget.Setup -> { - val inputs = SecureBackupSetupNode.Inputs( - isChangeRecoveryKeyUserStory = false, + secureBackupSetupEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + inputs = SecureBackupSetupEntryPoint.Inputs(isChangeRecoveryKeyUserStory = false), ) - createNode(buildContext, listOf(inputs)) } NavTarget.Change -> { - val inputs = SecureBackupSetupNode.Inputs( - isChangeRecoveryKeyUserStory = true, + secureBackupSetupEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + inputs = SecureBackupSetupEntryPoint.Inputs(isChangeRecoveryKeyUserStory = true), ) - createNode(buildContext, listOf(inputs)) } NavTarget.Disable -> { createNode(buildContext) diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPointTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPointTest.kt index 9e984f1ec0..6c2cb9984f 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPointTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPointTest.kt @@ -32,6 +32,7 @@ class DefaultSecureBackupEntryPointTest { SecureBackupFlowNode( buildContext = buildContext, plugins = plugins, + secureBackupSetupEntryPoint = DefaultSecureBackupSetupEntryPoint(), ) } val callback = object : SecureBackupEntryPoint.Callback { From a050716123eba0323bb506bf53460b42e4cc3629 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 8 Jun 2026 15:36:02 -0700 Subject: [PATCH 09/23] Remove custom recovery passphrase flow from FOSS secure backup setup The custom passphrase entry/confirm UI now lives in the enterprise secure backup module behind the SecureBackupSetupEntryPoint seam. Reverts the setup presenter, view, state, state machine, state provider, and events to the auto-generated-key-only flow, deletes CustomPassphraseDerivations and the temporary strings, and drops the well-known and enterprise-test dependencies the feature required. Co-Authored-By: Claude Opus 4.8 (1M context) --- features/securebackup/impl/build.gradle.kts | 3 - .../impl/setup/CustomPassphraseDerivations.kt | 51 -- .../impl/setup/SecureBackupSetupEvents.kt | 18 - .../impl/setup/SecureBackupSetupPresenter.kt | 206 +---- .../impl/setup/SecureBackupSetupState.kt | 24 - .../setup/SecureBackupSetupStateMachine.kt | 6 - .../setup/SecureBackupSetupStateProvider.kt | 140 +--- .../impl/setup/SecureBackupSetupView.kt | 376 +-------- .../impl/src/main/res/values/temporary.xml | 35 - .../setup/SecureBackupSetupPresenterTest.kt | 756 +----------------- 10 files changed, 70 insertions(+), 1545 deletions(-) delete mode 100644 features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt delete mode 100644 features/securebackup/impl/src/main/res/values/temporary.xml diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts index d7dafc3e65..54d87ef22e 100644 --- a/features/securebackup/impl/build.gradle.kts +++ b/features/securebackup/impl/build.gradle.kts @@ -38,12 +38,9 @@ dependencies { implementation(projects.libraries.oauth.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) - implementation(projects.libraries.wellknown.api) api(libs.statemachine) api(projects.features.securebackup.api) testCommonDependencies(libs, true) - testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.wellknown.test) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt deleted file mode 100644 index 7bf6e5805c..0000000000 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/CustomPassphraseDerivations.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.securebackup.impl.setup - -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements - -/** - * Validation flags computed once and shared by the presenter and the preview provider. - * - * Note: passphrase strength is intentionally NOT derived here. The estimation algorithm is an - * enterprise capability ([io.element.android.features.enterprise.api.EnterpriseService.estimateCustomRecoveryPassphraseStrength]); - * the presenter computes it via the injected service and the state provider supplies samples directly. - */ -internal data class CustomPassphraseDerivations( - val meetsMinLength: Boolean, - val mismatch: Boolean, - val canContinueFromEntry: Boolean, - val canSubmitCustomPassphrase: Boolean, -) - -internal fun deriveCustomPassphraseState( - requirements: CustomRecoveryPassphraseRequirements?, - passphrase: String, - confirm: String, - step: CustomEntryStep, - setupState: SetupState, -): CustomPassphraseDerivations { - val meetsMinLength = requirements?.isSatisfiedBy(passphrase) ?: true - val mismatch = confirm.isNotEmpty() && passphrase != confirm - val canContinueFromEntry = requirements != null && - passphrase.isNotEmpty() && - meetsMinLength && - setupState is SetupState.Init - val canSubmit = requirements != null && - passphrase.isNotEmpty() && - passphrase == confirm && - meetsMinLength && - step == CustomEntryStep.Confirm && - setupState is SetupState.Init - return CustomPassphraseDerivations( - meetsMinLength = meetsMinLength, - mismatch = mismatch, - canContinueFromEntry = canContinueFromEntry, - canSubmitCustomPassphrase = canSubmit, - ) -} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt index faf3d618fe..f61e65ba61 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt @@ -13,22 +13,4 @@ sealed interface SecureBackupSetupEvents { data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents data object Done : SecureBackupSetupEvents data object DismissDialog : SecureBackupSetupEvents - - /** Update the user-typed custom recovery passphrase. */ - data class UpdateCustomPassphrase(val value: String) : SecureBackupSetupEvents - - /** Update the user-typed confirmation field. */ - data class UpdateCustomPassphraseConfirm(val value: String) : SecureBackupSetupEvents - - /** Advance from the Entry step to the Confirm step. No-op unless the entry passphrase meets requirements. */ - data object ContinueCustomPassphrase : SecureBackupSetupEvents - - /** Step back from Confirm to Entry. Both typed values are preserved. */ - data object BackToCustomEntry : SecureBackupSetupEvents - - /** Submit the custom passphrase to the SDK (only valid when [SecureBackupSetupState.canSubmitCustomPassphrase]). */ - data object SubmitCustomPassphrase : SecureBackupSetupEvents - - /** Abort an in-flight custom submit (back press while creating) and return to the Confirm step. */ - data object CancelCustomPassphraseSubmit : SecureBackupSetupEvents } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt index 9145b4a49b..9f27bc9566 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt @@ -11,7 +11,6 @@ package io.element.android.features.securebackup.impl.setup import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -23,33 +22,22 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.securebackup.impl.loggerTagSetup import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EncryptionService -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements -import io.element.android.libraries.wellknown.api.SessionWellknownRetriever -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import timber.log.Timber -private data class WellknownStatus( - val loaded: Boolean, - val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?, -) - @AssistedInject class SecureBackupSetupPresenter( @Assisted private val isChangeRecoveryKeyUserStory: Boolean, private val stateMachine: SecureBackupSetupStateMachine, private val encryptionService: EncryptionService, - private val sessionWellknownRetriever: SessionWellknownRetriever, - private val enterpriseService: EnterpriseService, ) : Presenter { @AssistedFactory interface Factory { @@ -65,81 +53,10 @@ class SecureBackupSetupPresenter( } var showSaveConfirmationDialog by remember { mutableStateOf(false) } - // Spinner until the well-known fetch settles, so the user can't start the auto-gen path - // while a custom spec is still in flight. The loaded/specs pair flips in one assignment. - val wellknownStatusState = remember { - mutableStateOf(WellknownStatus(loaded = false, customRecoveryPassphraseRequirements = null)) - } - val wellknownLoaded by remember { - derivedStateOf { wellknownStatusState.value.loaded } - } - val customRecoveryPassphraseRequirements by remember { - derivedStateOf { wellknownStatusState.value.customRecoveryPassphraseRequirements } - } - // Not rememberSaveable: the passphrase must never reach the on-disk saved-state bundle. - // Plain String (not a zeroed CharArray): the TextField/event/SDK boundary all copy Strings - // anyway, so we just clear it on success and accept loss on process death. - var customPassphrase by remember { mutableStateOf("") } - var customPassphraseConfirm by remember { mutableStateOf("") } - var customEntryStep by remember { mutableStateOf(CustomEntryStep.Entry) } - // Single-flight guard: a button tap and an IME-Done in the same frame both see - // canSubmit=true and would each launch an enableRecovery coroutine. The state machine - // dedupes UserCreatesKey, but only this flag stops the duplicate SDK call. - var customSubmitInFlight by remember { mutableStateOf(false) } - // Handle to the in-flight custom submit so a back press can abort it (see CancelCustomPassphraseSubmit). - var customSubmitJob by remember { mutableStateOf(null) } - - LaunchedEffect(setupState) { - if (setupState !is SetupState.Creating) { - customSubmitInFlight = false - } - } - - LaunchedEffect(Unit) { - val result = sessionWellknownRetriever.getElementWellKnown() - // Enterprise gate: even if the homeserver advertises a custom spec, only honor it on - // builds where the feature is enabled. FOSS builds always fall back to the auto-gen path. - val specsFromWellknown = (result as? WellknownRetrieverResult.Success)?.data?.customRecoveryPassphraseRequirements - wellknownStatusState.value = WellknownStatus( - loaded = true, - customRecoveryPassphraseRequirements = specsFromWellknown.takeIf { enterpriseService.isCustomRecoveryPassphraseEnabled() }, - ) - } - - // Shared with SecureBackupSetupStateProvider so previews never drift from runtime. - val derivations by remember { - derivedStateOf { - deriveCustomPassphraseState( - requirements = wellknownStatusState.value.customRecoveryPassphraseRequirements, - passphrase = customPassphrase, - confirm = customPassphraseConfirm, - step = customEntryStep, - setupState = setupState, - ) - } - } - - // Strength is an enterprise-only estimation; null while the field is empty or in FOSS builds. - // Only computed on the Entry step, the only place the indicator is rendered. - val customPassphraseStrength by remember { - derivedStateOf { - customPassphrase - .takeIf { customEntryStep == CustomEntryStep.Entry && it.isNotEmpty() } - ?.let { enterpriseService.estimateCustomRecoveryPassphraseStrength(it) } - } - } - - // Nothing to "save" when the user chose the passphrase: auto-skip the Created step. - LaunchedEffect(setupState, customRecoveryPassphraseRequirements) { - if (customRecoveryPassphraseRequirements != null && setupState is SetupState.Created) { - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) - } - } - fun handleEvent(event: SecureBackupSetupEvents) { when (event) { SecureBackupSetupEvents.CreateRecoveryKey -> { - coroutineScope.createOrChangeRecoveryKey(stateAndDispatch, passphrase = null) + coroutineScope.createOrChangeRecoveryKey(stateAndDispatch) } SecureBackupSetupEvents.RecoveryKeyHasBeenSaved -> stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) @@ -150,51 +67,12 @@ class SecureBackupSetupPresenter( SecureBackupSetupEvents.Done -> { showSaveConfirmationDialog = true } - is SecureBackupSetupEvents.UpdateCustomPassphrase -> { - customPassphrase = event.value - } - is SecureBackupSetupEvents.UpdateCustomPassphraseConfirm -> { - customPassphraseConfirm = event.value - } - SecureBackupSetupEvents.ContinueCustomPassphrase -> { - if (derivations.canContinueFromEntry) { - customEntryStep = CustomEntryStep.Confirm - } - } - SecureBackupSetupEvents.BackToCustomEntry -> { - customEntryStep = CustomEntryStep.Entry - } - SecureBackupSetupEvents.SubmitCustomPassphrase -> { - if (derivations.canSubmitCustomPassphrase && !customSubmitInFlight) { - customSubmitInFlight = true - customSubmitJob = coroutineScope.createOrChangeRecoveryKey( - stateAndDispatch, - passphrase = customPassphrase, - onSuccess = { - customPassphrase = "" - customPassphraseConfirm = "" - }, - ) - } - } - SecureBackupSetupEvents.CancelCustomPassphraseSubmit -> { - // Abort the SDK call and snap back to Initial. Dispatch the reset first so that - // if the (now cancelled) coroutine still manages to dispatch SdkError/ - // SdkHasCreatedKey, those land in Initial where the state machine ignores them. - customSubmitJob?.cancel() - customSubmitJob = null - customSubmitInFlight = false - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCancelledCreate) - } } } - val isCustomFlow = customRecoveryPassphraseRequirements != null val recoveryKeyViewState = RecoveryKeyViewState( recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup, - // Custom flow: never surface the SDK base58 key in view state, even during the - // brief Created/CreatedAndSaved auto-skip window. - formattedRecoveryKey = if (isCustomFlow) null else setupState.recoveryKey(), + formattedRecoveryKey = setupState.recoveryKey(), displayTextFieldContents = true, inProgress = setupState is SetupState.Creating, ) @@ -204,16 +82,6 @@ class SecureBackupSetupPresenter( recoveryKeyViewState = recoveryKeyViewState, setupState = setupState, showSaveConfirmationDialog = showSaveConfirmationDialog, - wellknownLoaded = wellknownLoaded, - customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements, - customEntryStep = customEntryStep, - customPassphrase = customPassphrase, - customPassphraseConfirm = customPassphraseConfirm, - customPassphraseMeetsMinLength = derivations.meetsMinLength, - customPassphraseMismatch = derivations.mismatch, - customPassphraseStrength = customPassphraseStrength, - canContinueFromEntry = derivations.canContinueFromEntry, - canSubmitCustomPassphrase = derivations.canSubmitCustomPassphrase, eventSink = ::handleEvent, ) } @@ -230,43 +98,47 @@ class SecureBackupSetupPresenter( } private fun CoroutineScope.createOrChangeRecoveryKey( - stateAndDispatch: StateAndDispatch, - passphrase: String?, - onSuccess: () -> Unit = {}, + stateAndDispatch: StateAndDispatch ) = launch { stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey) - // Custom passphrase → enableRecovery(passphrase): the SDK derives the 4S key from it. - // For the Change flow this rotates in place — the SDK skips backup creation when recovery - // is already enabled (confirmed), so there's no key-backup teardown or room-key re-upload. - val result = if (passphrase != null) { - Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=present)") - encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = passphrase) - } else if (isChangeRecoveryKeyUserStory) { + if (isChangeRecoveryKeyUserStory) { Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()") - encryptionService.resetRecoveryKey() + encryptionService.resetRecoveryKey().fold( + onSuccess = { + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it)) + }, + onFailure = { + if (it is Exception) { + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it)) + } + } + ) } else { - Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery(passphrase=absent)") - encryptionService.enableRecovery(waitForBackupsToUpload = false, passphrase = null) - } - result.fold( - onSuccess = { key -> - // Clear buffers only on success; on failure keep them so the user can retry - // without retyping. - onSuccess() - // Custom flow: scrub the SDK base58 key from the state machine so SetupState - // never carries it. RustEncryptionService likewise keeps it out of - // enableRecoveryProgressStateFlow for the passphrase path, so it is not retained - // anywhere once this local goes out of scope. - val storedKey = if (passphrase != null) "" else key - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(storedKey)) - }, - onFailure = { + observeEncryptionService(stateAndDispatch) + Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()") + encryptionService.enableRecovery(waitForBackupsToUpload = false).onFailure { Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery") - // The state machine only accepts Exception; wrap anything else so a failure - // still leaves the Creating spinner instead of hanging on it. - val exception = it as? Exception ?: RuntimeException(it) - stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(exception)) + if (it is Exception) { + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it)) + } } - ) + } + } + + private fun CoroutineScope.observeEncryptionService( + stateAndDispatch: StateAndDispatch + ) = launch { + encryptionService.enableRecoveryProgressStateFlow.collect { enableRecoveryProgress -> + Timber.tag(loggerTagSetup.value).d("New enableRecoveryProgress: ${enableRecoveryProgress.javaClass.simpleName}") + when (enableRecoveryProgress) { + is EnableRecoveryProgress.Starting, + is EnableRecoveryProgress.CreatingBackup, + is EnableRecoveryProgress.CreatingRecoveryKey, + is EnableRecoveryProgress.BackingUp, + is EnableRecoveryProgress.RoomKeyUploadError -> Unit + is EnableRecoveryProgress.Done -> + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(enableRecoveryProgress.recoveryKey)) + } + } } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt index 0e5b60dab7..752b5e4851 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt @@ -8,40 +8,16 @@ package io.element.android.features.securebackup.impl.setup -import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements data class SecureBackupSetupState( val isChangeRecoveryKeyUserStory: Boolean, val recoveryKeyViewState: RecoveryKeyViewState, val showSaveConfirmationDialog: Boolean, val setupState: SetupState, - /** False while the well-known is still being fetched; the view shows a spinner until true. */ - val wellknownLoaded: Boolean, - /** Non-null when the well-known requires a user-chosen passphrase; hides the generated-key UI. */ - val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?, - val customEntryStep: CustomEntryStep, - /** User-typed passphrase. Not persisted to saved-state; cleared after a successful submit. */ - val customPassphrase: String, - /** Confirmation field; same lifetime contract as [customPassphrase]. */ - val customPassphraseConfirm: String, - /** True when [customPassphrase] satisfies the minimum-character-count rule from [customRecoveryPassphraseRequirements]. */ - val customPassphraseMeetsMinLength: Boolean, - val customPassphraseMismatch: Boolean, - /** Null while the Entry-step passphrase is empty; otherwise the latest strength reading rendered below the field. */ - val customPassphraseStrength: CustomRecoveryPassphraseStrengthResult?, - /** True when the Entry-step passphrase is valid enough to advance to the Confirm step. */ - val canContinueFromEntry: Boolean, - val canSubmitCustomPassphrase: Boolean, val eventSink: (SecureBackupSetupEvents) -> Unit ) -enum class CustomEntryStep { - Entry, - Confirm, -} - sealed interface SetupState { data object Init : SetupState data object Creating : SetupState diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt index 62478e3b87..150aeeddc7 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt @@ -34,9 +34,6 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine -> state.override { State.KeyCreated(event.key) } } - on { _: Event.UserCancelledCreate, state: MachineState -> - state.override { State.Initial } - } } inState { on { _: Event.UserSavedKey, state: MachineState -> @@ -67,8 +64,5 @@ class SecureBackupSetupStateMachine : FlowReduxStateMachine { override val values: Sequence get() = sequenceOf( - aSecureBackupSetupState(wellknownLoaded = false), aSecureBackupSetupState(setupState = SetupState.Init), aSecureBackupSetupState(setupState = SetupState.Creating), aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())), @@ -29,141 +25,25 @@ open class SecureBackupSetupStateProvider : PreviewParameterProvider Unit, modifier: Modifier = Modifier, ) { - // Custom flow auto-skips the "save your key" screen: finish once the SDK accepted it. - LaunchedEffect(state.setupState, state.isCustomEntry()) { - if (state.isCustomEntry() && state.setupState is SetupState.CreatedAndSaved) { - onSuccess() - } - } FlowStepPage( modifier = modifier, - onBackClick = backClickHandler(state, onBackClick), + onBackClick = onBackClick.takeIf { state.canGoBack() }, title = title(state), subTitle = subtitle(state), iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), - buttons = { Buttons(state, onFinish = onSuccess, onCancel = onBackClick) }, + buttons = { Buttons(state, onFinish = onSuccess) }, ) { Content(state = state) } @@ -119,45 +78,15 @@ private fun SecureBackupSetupState.canGoBack(): Boolean { return recoveryKeyViewState.formattedRecoveryKey == null } -private fun SecureBackupSetupState.isCustomEntry(): Boolean = - customRecoveryPassphraseRequirements != null - -private fun backClickHandler( - state: SecureBackupSetupState, - onBackClick: () -> Unit, -): (() -> Unit)? { - if (!state.canGoBack()) return null - if (state.isCustomEntry()) { - // Back while the SDK call is in flight aborts it and returns to the Confirm step - // (typed input preserved) rather than silently flipping the step under the spinner. - if (state.setupState is SetupState.Creating) { - return { state.eventSink.invoke(SecureBackupSetupEvents.CancelCustomPassphraseSubmit) } - } - if (state.customEntryStep == CustomEntryStep.Confirm) { - // In the custom flow, backing out of Confirm returns to Entry (preserve typed input). - return { state.eventSink.invoke(SecureBackupSetupEvents.BackToCustomEntry) } - } - } - return onBackClick -} - @Composable private fun title(state: SecureBackupSetupState): String { - // Hide the heading until the well-known resolves, so it can't flip copy under the user. - if (!state.wellknownLoaded) return "" - // Custom flow has no "save your key" step — use per-step custom titles throughout. - if (state.isCustomEntry()) { - return when (state.customEntryStep) { - CustomEntryStep.Entry -> stringResource(id = R.string.pro_screen_recovery_key_mode_input_title) - CustomEntryStep.Confirm -> stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_title) - } - } return when (state.setupState) { SetupState.Init, SetupState.Creating, - is SetupState.Error -> when { - state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_title) - else -> stringResource(id = R.string.screen_recovery_key_setup_title) + is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) { + stringResource(id = R.string.screen_recovery_key_change_title) + } else { + stringResource(id = R.string.screen_recovery_key_setup_title) } is SetupState.Created, is SetupState.CreatedAndSaved -> @@ -166,20 +95,14 @@ private fun title(state: SecureBackupSetupState): String { } @Composable -private fun subtitle(state: SecureBackupSetupState): String? { - if (!state.wellknownLoaded) return null - if (state.isCustomEntry()) { - return when (state.customEntryStep) { - CustomEntryStep.Entry -> stringResource(id = R.string.pro_screen_recovery_key_mode_input_description) - CustomEntryStep.Confirm -> stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_description) - } - } +private fun subtitle(state: SecureBackupSetupState): String { return when (state.setupState) { SetupState.Init, SetupState.Creating, - is SetupState.Error -> when { - state.isChangeRecoveryKeyUserStory -> stringResource(id = R.string.screen_recovery_key_change_description) - else -> stringResource(id = R.string.screen_recovery_key_setup_description) + is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) { + stringResource(id = R.string.screen_recovery_key_change_description) + } else { + stringResource(id = R.string.screen_recovery_key_setup_description) } is SetupState.Created, is SetupState.CreatedAndSaved -> @@ -191,25 +114,6 @@ private fun subtitle(state: SecureBackupSetupState): String? { private fun Content( state: SecureBackupSetupState, ) { - // Spinner until the well-known resolves, so neither flow renders before we know which applies. - if (!state.wellknownLoaded && state.setupState == SetupState.Init) { - LoadingPlaceholder() - return - } - if (state.isCustomEntry()) { - when (state.setupState) { - SetupState.Init, - is SetupState.Error -> when (state.customEntryStep) { - CustomEntryStep.Entry -> CustomPassphraseEntry(state = state) - CustomEntryStep.Confirm -> CustomPassphraseConfirm(state = state) - } - // Hold the spinner through the auto-skip so the SDK base58 key is never shown/shared. - SetupState.Creating, - is SetupState.Created, - is SetupState.CreatedAndSaved -> LoadingPlaceholder() - } - return - } val context = LocalContext.current val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard) @@ -240,268 +144,10 @@ private fun Content( ) } -@Composable -private fun LoadingPlaceholder() { - val description = stringResource(id = R.string.a11y_recovery_key_loading_specs) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 52.dp) - .semantics { contentDescription = description }, - contentAlignment = Alignment.Center, - ) { - // CircularProgressIndicator applies progressSemantics() internally — no need to add it here. - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } -} - -@Composable -private fun CustomPassphraseEntry(state: SecureBackupSetupState) { - val specs = state.customRecoveryPassphraseRequirements ?: return - var passphraseVisible by rememberSaveable { mutableStateOf(false) } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - ) { - TextField( - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.customRecoveryPassphrase) - .semantics { contentType = ContentType.NewPassword }, - value = state.customPassphrase, - onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(it)) }, - label = stringResource(id = CommonStrings.common_recovery_key), - singleLine = true, - visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - PasswordVisibilityToggle( - visible = passphraseVisible, - onToggle = { passphraseVisible = !passphraseVisible }, - ) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { - if (state.canContinueFromEntry) { - state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - } - }, - ), - supportingText = stringResource( - id = R.string.pro_screen_recovery_key_mode_input_passphrase_field_footer, - specs.minCharacterCount, - ), - ) - // Always render the strength row in the Entry step (matches iOS): an empty field shows the - // "Strength" base label with an empty bar; typing flips it to the per-level label + bar. - Spacer(modifier = Modifier.height(8.dp)) - PassphraseStrengthIndicator( - result = state.customPassphraseStrength, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@Composable -private fun PassphraseStrengthIndicator( - result: CustomRecoveryPassphraseStrengthResult?, - modifier: Modifier = Modifier, -) { - val label = stringResource( - id = result?.strength?.labelRes() ?: R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_base, - ) - val hint = result?.strength?.hintRes()?.let { stringResource(id = it) } - val score = result?.score ?: 0f - // Mirror iOS: a zero score (empty field or the Garbage tier) stays neutral; otherwise the - // label and bar share a single colour interpolated along the red→orange→yellow→green gradient. - val color = if (score <= 0f) ElementTheme.colors.textSecondary else passphraseStrengthColor(score) - val announcement = stringResource(id = R.string.a11y_recovery_key_custom_strength_announcement, label) - .let { if (hint != null) "$it. $hint" else it } - Column( - modifier = modifier.semantics(mergeDescendants = true) { - contentDescription = announcement - }, - ) { - Text( - text = label, - color = color, - style = ElementTheme.typography.fontBodySmMedium, - ) - Spacer(modifier = Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { score }, - modifier = Modifier.fillMaxWidth(), - color = color, - ) - if (hint != null) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = hint, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodySmRegular, - ) - } - } -} - -private fun CustomRecoveryPassphraseStrength.labelRes(): Int = when (this) { - CustomRecoveryPassphraseStrength.Garbage -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_garbage - CustomRecoveryPassphraseStrength.Weak -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_weak - CustomRecoveryPassphraseStrength.Moderate -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_moderate - CustomRecoveryPassphraseStrength.Okay -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_okay - CustomRecoveryPassphraseStrength.Strong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_strong - CustomRecoveryPassphraseStrength.VeryStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_very_strong - CustomRecoveryPassphraseStrength.UltraStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_ultra_strong - CustomRecoveryPassphraseStrength.Mega -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_mega -} - -private fun CustomRecoveryPassphraseStrength.hintRes(): Int = when (this) { - CustomRecoveryPassphraseStrength.Garbage -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_garbage - CustomRecoveryPassphraseStrength.Weak -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_weak - CustomRecoveryPassphraseStrength.Moderate -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_moderate - CustomRecoveryPassphraseStrength.Okay -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_okay - CustomRecoveryPassphraseStrength.Strong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_strong - CustomRecoveryPassphraseStrength.VeryStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_very_strong - CustomRecoveryPassphraseStrength.UltraStrong -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_ultra_strong - CustomRecoveryPassphraseStrength.Mega -> R.string.pro_screen_recovery_key_mode_input_passphrase_strength_label_hint_mega -} - -// Stops mirror iOS PasswordStrengthBar.gradient: Compound red900 / lime700 with the same vivid -// orange & yellow midpoints. Intentionally fixed (not light/dark semantic) so the gradient reads -// identically to iOS in both themes. -private val passphraseStrengthGradientStops = listOf( - 0.25f to Color(0xFFD51928), // Compound red900 - 0.5f to Color(0xFFFF9500), // orange - 0.75f to Color(0xFFFFCC00), // yellow - 1.0f to Color(0xFF54C424), // Compound lime700 -) - -/** Interpolates the strength bar colour along [passphraseStrengthGradientStops] for a 0f..1f score. */ -private fun passphraseStrengthColor(score: Float): Color { - val clamped = score.coerceIn(0f, 1f) - var previousLocation = 0f - var previousColor = passphraseStrengthGradientStops.first().second - for ((location, color) in passphraseStrengthGradientStops) { - if (clamped <= location) { - val span = location - previousLocation - if (span <= 0f) return color - return lerp(previousColor, color, ((clamped - previousLocation) / span).coerceIn(0f, 1f)) - } - previousLocation = location - previousColor = color - } - return previousColor -} - -@Composable -private fun CustomPassphraseConfirm(state: SecureBackupSetupState) { - var passphraseVisible by rememberSaveable { mutableStateOf(false) } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - ) { - TextField( - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.customRecoveryPassphraseConfirm) - .semantics { contentType = ContentType.NewPassword }, - value = state.customPassphraseConfirm, - onValueChange = { state.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(it)) }, - label = stringResource(id = CommonStrings.common_recovery_key), - singleLine = true, - visualTransformation = if (passphraseVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - PasswordVisibilityToggle( - visible = passphraseVisible, - onToggle = { passphraseVisible = !passphraseVisible }, - ) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { - if (state.canSubmitCustomPassphrase) { - state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - } - }, - ), - validity = if (state.customPassphraseMismatch) TextFieldValidity.Invalid else TextFieldValidity.None, - supportingText = if (state.customPassphraseMismatch) { - stringResource(id = R.string.pro_screen_recovery_key_mode_custom_mismatch) - } else { - null - }, - ) - } -} - @Composable private fun ColumnScope.Buttons( state: SecureBackupSetupState, onFinish: () -> Unit, - onCancel: () -> Unit, -) { - // No buttons until the well-known resolves; Content shows a spinner meanwhile. - if (!state.wellknownLoaded && state.setupState == SetupState.Init) return - if (state.isCustomEntry()) { - CustomButtons(state = state, onCancel = onCancel) - } else { - AutoGenButtons(state = state, onFinish = onFinish) - } -} - -@Composable -private fun ColumnScope.CustomButtons( - state: SecureBackupSetupState, - onCancel: () -> Unit, -) { - // No save/share controls in the custom flow — they'd leak the base58 key during Created. - when (state.setupState) { - SetupState.Creating, - is SetupState.Created, - is SetupState.CreatedAndSaved -> return - SetupState.Init, - is SetupState.Error -> Unit - } - when (state.customEntryStep) { - CustomEntryStep.Entry -> { - Button( - text = stringResource(id = CommonStrings.action_continue), - enabled = state.canContinueFromEntry, - modifier = Modifier.fillMaxWidth(), - onClick = { state.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) }, - ) - TextButton( - text = stringResource(id = CommonStrings.action_cancel), - modifier = Modifier.fillMaxWidth(), - onClick = onCancel, - ) - } - CustomEntryStep.Confirm -> { - Button( - text = stringResource(id = R.string.pro_screen_recovery_key_mode_confirm_finish_button), - enabled = state.canSubmitCustomPassphrase, - modifier = Modifier.fillMaxWidth(), - onClick = { state.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) }, - ) - } - } -} - -@Composable -private fun ColumnScope.AutoGenButtons( - state: SecureBackupSetupState, - onFinish: () -> Unit, ) { val context = LocalContext.current val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action) diff --git a/features/securebackup/impl/src/main/res/values/temporary.xml b/features/securebackup/impl/src/main/res/values/temporary.xml deleted file mode 100644 index c573a4c6e3..0000000000 --- a/features/securebackup/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - Enter a recovery key - Choose a recovery key that you can memorize. - Confirm your recovery key - Enter your recovery key again. - Minimum %1$s characters - Finish setup - Strength - Garbage - Weak - Moderate - Okay - Strong - Very Strong - Super Strong - Ideal - Cracking would take roughly milliseconds. - Cracking would take roughly minutes to months. - Cracking would take roughly years. - Cracking would take roughly hundreds of years. - Cracking would take roughly billions of years. - Cracking would take roughly trillions of years. - Cracking would take roughly quadrillions of years. - Cracking would take roughly quintillions of years. - - The recovery key you entered doesn\'t match - Loading recovery key requirements - Passphrase strength: %1$s - diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt index 6ca716bcd6..4aede3c534 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt @@ -6,37 +6,23 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) - package io.element.android.features.securebackup.impl.setup import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrength -import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult -import io.element.android.features.enterprise.api.EnterpriseService -import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState -import io.element.android.features.wellknown.test.FakeSessionWellknownRetriever -import io.element.android.features.wellknown.test.aCustomRecoveryPassphraseRequirements -import io.element.android.features.wellknown.test.anElementWellKnown +import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.test.A_RECOVERY_KEY import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService -import io.element.android.libraries.wellknown.api.SessionWellknownRetriever -import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -@Suppress("LargeClass") class SecureBackupSetupPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -47,16 +33,11 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val preFetch = awaitItem() - assertThat(preFetch.wellknownLoaded).isFalse() - assertThat(preFetch.setupState).isEqualTo(SetupState.Init) - - val loaded = awaitItem() - assertThat(loaded.wellknownLoaded).isTrue() - assertThat(loaded.isChangeRecoveryKeyUserStory).isFalse() - assertThat(loaded.setupState).isEqualTo(SetupState.Init) - assertThat(loaded.showSaveConfirmationDialog).isFalse() - assertThat(loaded.recoveryKeyViewState).isEqualTo( + val initialState = awaitItem() + assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse() + assertThat(initialState.setupState).isEqualTo(SetupState.Init) + assertThat(initialState.showSaveConfirmationDialog).isFalse() + assertThat(initialState.recoveryKeyViewState).isEqualTo( RecoveryKeyViewState( recoveryKeyUserStory = RecoveryKeyUserStory.Setup, formattedRecoveryKey = null, @@ -69,17 +50,14 @@ class SecureBackupSetupPresenterTest { @Test fun `present - create recovery key and save it`() = runTest { - val encryptionService = FakeEncryptionService( - enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) }, - ) + val encryptionService = FakeEncryptionService() val presenter = createSecureBackupSetupPresenter( encryptionService = encryptionService ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitItem() // pre-fetch - val initialState = awaitItem() // post-fetch, wellknownLoaded=true + val initialState = awaitItem() initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) val creatingState = awaitItem() assertThat(creatingState.setupState).isEqualTo(SetupState.Creating) @@ -91,6 +69,7 @@ class SecureBackupSetupPresenterTest { inProgress = true, ) ) + encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY)) val createdState = awaitItem() assertThat(createdState.setupState).isEqualTo(SetupState.Created(A_RECOVERY_KEY)) assertThat(createdState.recoveryKeyViewState).isEqualTo( @@ -121,9 +100,7 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitItem() // pre-fetch val initialState = awaitItem() - assertThat(initialState.wellknownLoaded).isTrue() assertThat(initialState.isChangeRecoveryKeyUserStory).isTrue() assertThat(initialState.setupState).isEqualTo(SetupState.Init) assertThat(initialState.recoveryKeyViewState).isEqualTo( @@ -149,7 +126,6 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitItem() // pre-fetch val initialState = awaitItem() assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse() assertThat(initialState.setupState).isEqualTo(SetupState.Init) @@ -176,7 +152,6 @@ class SecureBackupSetupPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitItem() // pre-fetch val initialState = awaitItem() initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) val creatingState = awaitItem() @@ -211,727 +186,16 @@ class SecureBackupSetupPresenterTest { } } - @Test - fun `present - wellknownLoaded flips false to true exactly once after fetch resolves`() = runTest { - val presenter = createSecureBackupSetupPresenter( - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val preFetch = awaitItem() - assertThat(preFetch.wellknownLoaded).isFalse() - assertThat(preFetch.customRecoveryPassphraseRequirements).isNull() - - val loaded = awaitItem() - assertThat(loaded.wellknownLoaded).isTrue() - assertThat(loaded.customRecoveryPassphraseRequirements).isNotNull() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec absent leaves customRecoveryPassphraseRequirements null and uses generated-key path`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = null)) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val preFetch = awaitItem() - assertThat(preFetch.wellknownLoaded).isFalse() - val loaded = awaitItem() - assertThat(loaded.wellknownLoaded).isTrue() - assertThat(loaded.customRecoveryPassphraseRequirements).isNull() - assertThat(loaded.canSubmitCustomPassphrase).isFalse() - loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) - awaitItem() // creating - advanceUntilIdle() - enableRecoveryLambda.assertions().isCalledOnce() - .with(value(false), value(null)) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec present surfaces validation errors and blocks continue`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // initial, pre-fetch - val withSpec = awaitItem() - assertThat(withSpec.customRecoveryPassphraseRequirements).isNotNull() - assertThat(withSpec.customEntryStep).isEqualTo(CustomEntryStep.Entry) - assertThat(withSpec.canContinueFromEntry).isFalse() - assertThat(withSpec.canSubmitCustomPassphrase).isFalse() - - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc")) - val afterShortInput = awaitItem() - assertThat(afterShortInput.customPassphrase).isEqualTo("abc") - assertThat(afterShortInput.customPassphraseMeetsMinLength).isFalse() - assertThat(afterShortInput.canContinueFromEntry).isFalse() - - // Continue is a no-op when entry is invalid: step stays Entry. - afterShortInput.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - expectNoEvents() - enableRecoveryLambda.assertions().isNeverCalled() - } - } - - @Test - fun `present - custom spec strength is null while empty and delegates to the enterprise estimator once user types`() = runTest { - // The estimation algorithm lives in the enterprise module; here we only verify the presenter - // suppresses the indicator while the field is empty and otherwise forwards the service result. - val presenter = createSecureBackupSetupPresenter( - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - enterpriseService = FakeEnterpriseService( - isCustomRecoveryPassphraseEnabledResult = true, - estimateCustomRecoveryPassphraseStrengthResult = { passphrase -> - if (passphrase.length > 5) { - CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.9f) - } else { - CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Weak, score = 0.1f) - } - }, - ), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val withSpec = awaitItem() - // Field is empty → indicator suppressed (the estimator is never consulted). - assertThat(withSpec.customPassphraseStrength).isNull() - - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc")) - val afterShort = awaitItem() - assertThat(afterShort.customPassphraseStrength?.strength) - .isEqualTo(CustomRecoveryPassphraseStrength.Weak) - - afterShort.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("Abcdefg1!@#xyz")) - val afterStrong = awaitItem() - assertThat(afterStrong.customPassphraseStrength?.strength) - .isEqualTo(CustomRecoveryPassphraseStrength.Strong) - - // Clearing the field brings the indicator back to null. - afterStrong.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("")) - val cleared = awaitItem() - assertThat(cleared.customPassphraseStrength).isNull() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec ContinueCustomPassphrase advances Entry to Confirm when valid`() = runTest { - val presenter = createSecureBackupSetupPresenter( - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // initial - val withSpec = awaitItem() - assertThat(withSpec.customEntryStep).isEqualTo(CustomEntryStep.Entry) - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - val typed = awaitItem() - assertThat(typed.canContinueFromEntry).isTrue() - - typed.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val advanced = awaitItem() - assertThat(advanced.customEntryStep).isEqualTo(CustomEntryStep.Confirm) - assertThat(advanced.customPassphrase).isEqualTo(valid) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec BackToCustomEntry returns to Entry preserving both fields`() = runTest { - val presenter = createSecureBackupSetupPresenter( - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // initial - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - awaitItem() - withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm) - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - awaitItem() - - onConfirm.eventSink.invoke(SecureBackupSetupEvents.BackToCustomEntry) - val backOnEntry = awaitItem() - assertThat(backOnEntry.customEntryStep).isEqualTo(CustomEntryStep.Entry) - assertThat(backOnEntry.customPassphrase).isEqualTo(valid) - assertThat(backOnEntry.customPassphraseConfirm).isEqualTo(valid) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec SubmitCustomPassphrase from Entry step is a no-op`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // initial - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - val typed = awaitItem() - // Both fields contain matching content via a stale path: simulate by also setting confirm - // while still on the Entry step (e.g., previously typed). Submit must still be gated. - typed.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val bothTyped = awaitItem() - assertThat(bothTyped.customEntryStep).isEqualTo(CustomEntryStep.Entry) - assertThat(bothTyped.canSubmitCustomPassphrase).isFalse() - - bothTyped.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - expectNoEvents() - enableRecoveryLambda.assertions().isNeverCalled() - } - } - - @Test - fun `present - custom spec present submit with valid input forwards passphrase to SDK`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // initial - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - val afterKey = awaitItem() - afterKey.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm) - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val ready = awaitItem() - assertThat(ready.canSubmitCustomPassphrase).isTrue() - - ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - awaitItem() // creating - advanceUntilIdle() - enableRecoveryLambda.assertions().isCalledOnce() - .with(value(false), value(valid)) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec error retains typed passphrase and Confirm step across DismissDialog`() = runTest { - val encryptionService = FakeEncryptionService( - enableRecoveryLambda = { _, _ -> Result.failure(IllegalStateException("boom")) }, - ) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - awaitItem() - withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val ready = awaitItem() - assertThat(ready.canSubmitCustomPassphrase).isTrue() - - ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - awaitItem() // creating - val errored = awaitItem() - assertThat(errored.setupState).isInstanceOf(SetupState.Error::class.java) - - errored.eventSink.invoke(SecureBackupSetupEvents.DismissDialog) - val afterDismiss = awaitItem() - assertThat(afterDismiss.setupState).isEqualTo(SetupState.Init) - assertThat(afterDismiss.customPassphrase).isEqualTo(valid) - assertThat(afterDismiss.customPassphraseConfirm).isEqualTo(valid) - assertThat(afterDismiss.customEntryStep).isEqualTo(CustomEntryStep.Confirm) - assertThat(afterDismiss.canSubmitCustomPassphrase).isTrue() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec submit success auto-advances to CreatedAndSaved without manual save`() = runTest { - val encryptionService = FakeEncryptionService( - enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) }, - ) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - awaitItem() - withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val ready = awaitItem() - assertThat(ready.canSubmitCustomPassphrase).isTrue() - - ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - awaitItem() // creating - advanceUntilIdle() - - // The presenter's auto-skip LaunchedEffect should have advanced state machine past - // Created without requiring a manual RecoveryKeyHasBeenSaved event. The terminal - // state observed is CreatedAndSaved. - val finalState = expectMostRecentItem() - assertThat(finalState.setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java) - // The SDK-derived base58 key must not leak through the recoveryKeyViewState in - // the custom-passphrase flow. - assertThat(finalState.recoveryKeyViewState.formattedRecoveryKey).isNull() - // The state machine's KeyCreatedAndSaved variant carries a key too — confirm - // the presenter scrubbed it before dispatching SdkHasCreatedKey, so observers - // of setupState don't see the SDK-generated base58 key either. - assertThat(finalState.setupState).isEqualTo(SetupState.CreatedAndSaved(formattedRecoveryKey = "")) - assertThat(finalState.setupState.recoveryKey()).isEqualTo("") - // Typed passphrase is cleared once the SDK call returns successfully. - assertThat(finalState.customPassphrase).isEmpty() - assertThat(finalState.customPassphraseConfirm).isEmpty() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - enterprise feature disabled suppresses spec and forces auto-gen path`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - enterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = false), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val loaded = awaitItem() - assertThat(loaded.wellknownLoaded).isTrue() - // Even though the homeserver advertised a spec, the enterprise gate suppresses it. - assertThat(loaded.customRecoveryPassphraseRequirements).isNull() - assertThat(loaded.canSubmitCustomPassphrase).isFalse() - loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) - awaitItem() // creating - advanceUntilIdle() - enableRecoveryLambda.assertions().isCalledOnce() - .with(value(false), value(null)) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - well-known fetch failure falls back to generated-key path`() = runTest { - val presenter = createSecureBackupSetupPresenter( - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Error(IllegalStateException("boom")) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val preFetch = awaitItem() - assertThat(preFetch.wellknownLoaded).isFalse() - val loaded = awaitItem() - assertThat(loaded.wellknownLoaded).isTrue() - assertThat(loaded.customRecoveryPassphraseRequirements).isNull() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - change + custom spec absent uses resetRecoveryKey path`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - isChangeRecoveryKeyUserStory = true, - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = null)) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val loaded = awaitItem() - assertThat(loaded.customRecoveryPassphraseRequirements).isNull() - loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) - awaitItem() // creating - val createdState = awaitItem() - assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY)) - enableRecoveryLambda.assertions().isNeverCalled() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - change + custom spec present surfaces validation errors and blocks continue`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - isChangeRecoveryKeyUserStory = true, - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // initial, pre-fetch - val withSpec = awaitItem() - assertThat(withSpec.customRecoveryPassphraseRequirements).isNotNull() - assertThat(withSpec.canContinueFromEntry).isFalse() - - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase("abc")) - val afterShortInput = awaitItem() - assertThat(afterShortInput.customPassphrase).isEqualTo("abc") - assertThat(afterShortInput.customPassphraseMeetsMinLength).isFalse() - assertThat(afterShortInput.canContinueFromEntry).isFalse() - - afterShortInput.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - expectNoEvents() - enableRecoveryLambda.assertions().isNeverCalled() - } - } - - @Test - fun `present - change + custom spec present submit with valid input forwards passphrase to enableRecovery`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - isChangeRecoveryKeyUserStory = true, - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // initial - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - val afterKey = awaitItem() - afterKey.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val ready = awaitItem() - assertThat(ready.canSubmitCustomPassphrase).isTrue() - - ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - awaitItem() // creating - advanceUntilIdle() - enableRecoveryLambda.assertions().isCalledOnce() - .with(value(false), value(valid)) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - change + well-known fetch failure falls back to resetRecoveryKey path`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - isChangeRecoveryKeyUserStory = true, - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Error(IllegalStateException("boom")) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val loaded = awaitItem() - assertThat(loaded.customRecoveryPassphraseRequirements).isNull() - loaded.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) - awaitItem() // creating - val createdState = awaitItem() - assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY)) - enableRecoveryLambda.assertions().isNeverCalled() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec Confirm mismatch clears once user edits to match`() = runTest { - val presenter = createSecureBackupSetupPresenter( - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - awaitItem() - withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - assertThat(onConfirm.customPassphraseMismatch).isFalse() - - // Typing a different value flips mismatch=true and blocks submit. - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm("differen")) - val mismatched = awaitItem() - assertThat(mismatched.customPassphraseMismatch).isTrue() - assertThat(mismatched.canSubmitCustomPassphrase).isFalse() - - // Editing the confirm field to match clears the mismatch and re-enables submit. - mismatched.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val matched = awaitItem() - assertThat(matched.customPassphraseMismatch).isFalse() - assertThat(matched.canSubmitCustomPassphrase).isTrue() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec rapid double-submit invokes the SDK only once`() = runTest { - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - awaitItem() - withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val ready = awaitItem() - assertThat(ready.canSubmitCustomPassphrase).isTrue() - - // Two synchronous submits dispatched from the same snapshot (e.g., button + IME-Done - // racing). The presenter's in-flight guard must drop the second one before it - // launches a second enableRecovery coroutine. - ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - advanceUntilIdle() - enableRecoveryLambda.assertions().isCalledOnce() - .with(value(false), value(valid)) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - change + custom spec submit routes to enableRecovery and never resetRecoveryKey`() = runTest { - // Guards the custom-passphrase Change path against silently dropping the passphrase: - // the user's passphrase reaches the SDK only via enableRecovery(passphrase); the - // passphrase-less resetRecoveryKey() path must never be taken here. A true end-to-end - // check that the 4S key actually rotates needs the real SDK + homeserver and lives - // outside this JVM suite. - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val resetRecoveryKeyLambda = lambdaRecorder> { Result.success(FakeEncryptionService.FAKE_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService( - enableRecoveryLambda = enableRecoveryLambda, - resetRecoveryKeyLambda = resetRecoveryKeyLambda, - ) - val presenter = createSecureBackupSetupPresenter( - isChangeRecoveryKeyUserStory = true, - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - awaitItem() - withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val ready = awaitItem() - assertThat(ready.canSubmitCustomPassphrase).isTrue() - - ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - awaitItem() // creating - advanceUntilIdle() - enableRecoveryLambda.assertions().isCalledOnce() - .with(value(false), value(valid)) - resetRecoveryKeyLambda.assertions().isNeverCalled() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - custom spec cancel while creating aborts the SDK call and returns to Confirm with input preserved`() = runTest { - // simulateLongTask suspends on delay(1) before invoking the lambda, so cancelling before - // advancing virtual time guarantees the SDK call never runs. - val enableRecoveryLambda = lambdaRecorder> { _, _ -> Result.success(A_RECOVERY_KEY) } - val encryptionService = FakeEncryptionService(enableRecoveryLambda = enableRecoveryLambda) - val presenter = createSecureBackupSetupPresenter( - encryptionService = encryptionService, - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - awaitItem() - withSpec.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - onConfirm.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphraseConfirm(valid)) - val ready = awaitItem() - assertThat(ready.canSubmitCustomPassphrase).isTrue() - - ready.eventSink.invoke(SecureBackupSetupEvents.SubmitCustomPassphrase) - val creating = awaitItem() - assertThat(creating.setupState).isEqualTo(SetupState.Creating) - - creating.eventSink.invoke(SecureBackupSetupEvents.CancelCustomPassphraseSubmit) - val cancelled = awaitItem() - assertThat(cancelled.setupState).isEqualTo(SetupState.Init) - assertThat(cancelled.customEntryStep).isEqualTo(CustomEntryStep.Confirm) - assertThat(cancelled.customPassphrase).isEqualTo(valid) - assertThat(cancelled.customPassphraseConfirm).isEqualTo(valid) - assertThat(cancelled.canSubmitCustomPassphrase).isTrue() - - // The cancelled coroutine never reached the SDK, and no zombie success/error followed. - advanceUntilIdle() - enableRecoveryLambda.assertions().isNeverCalled() - expectNoEvents() - } - } - - @Test - fun `present - custom spec strength indicator is suppressed on the Confirm step`() = runTest { - val presenter = createSecureBackupSetupPresenter( - sessionWellknownRetriever = FakeSessionWellknownRetriever { - WellknownRetrieverResult.Success(anElementWellKnown(customRecoveryPassphraseRequirements = aCustomRecoveryPassphraseRequirements())) - }, - enterpriseService = FakeEnterpriseService( - isCustomRecoveryPassphraseEnabledResult = true, - estimateCustomRecoveryPassphraseStrengthResult = { - CustomRecoveryPassphraseStrengthResult(CustomRecoveryPassphraseStrength.Strong, score = 0.9f) - }, - ), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - awaitItem() // pre-fetch - val withSpec = awaitItem() - - val valid = "Ab12!@cd" - withSpec.eventSink.invoke(SecureBackupSetupEvents.UpdateCustomPassphrase(valid)) - val onEntry = awaitItem() - // Entry step: the estimator result is surfaced. - assertThat(onEntry.customPassphraseStrength?.strength).isEqualTo(CustomRecoveryPassphraseStrength.Strong) - - onEntry.eventSink.invoke(SecureBackupSetupEvents.ContinueCustomPassphrase) - val onConfirm = awaitItem() - // Confirm step: indicator is not rendered, so the presenter stops computing it. - assertThat(onConfirm.customEntryStep).isEqualTo(CustomEntryStep.Confirm) - assertThat(onConfirm.customPassphraseStrength).isNull() - cancelAndIgnoreRemainingEvents() - } - } - private fun createSecureBackupSetupPresenter( isChangeRecoveryKeyUserStory: Boolean = false, encryptionService: EncryptionService = FakeEncryptionService( - enableRecoveryLambda = { _, _ -> Result.success(A_RECOVERY_KEY) }, + enableRecoveryLambda = { _, _ -> Result.success("") }, ), - sessionWellknownRetriever: SessionWellknownRetriever = FakeSessionWellknownRetriever(), - enterpriseService: EnterpriseService = FakeEnterpriseService(isCustomRecoveryPassphraseEnabledResult = true), ): SecureBackupSetupPresenter { return SecureBackupSetupPresenter( isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = encryptionService, - sessionWellknownRetriever = sessionWellknownRetriever, - enterpriseService = enterpriseService, ) } } From 6710b1c9c1cfb0be0f651ea9c909494c89014e4c Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Mon, 8 Jun 2026 15:36:16 -0700 Subject: [PATCH 10/23] Remove custom recovery passphrase API from EnterpriseService These methods gated the feature at runtime. The gate is now a DI binding swap in the enterprise build (EnterpriseSecureBackupSetupEntryPoint replaces the FOSS default), so the EnterpriseService surface and the CustomRecoveryPassphraseStrength result types are no longer needed in FOSS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/CustomRecoveryPassphraseStrength.kt | 35 ------------------- .../enterprise/api/EnterpriseService.kt | 16 --------- .../impl/DefaultEnterpriseService.kt | 5 --- .../enterprise/test/FakeEnterpriseService.kt | 8 ----- 4 files changed, 64 deletions(-) delete mode 100644 features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/CustomRecoveryPassphraseStrength.kt diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/CustomRecoveryPassphraseStrength.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/CustomRecoveryPassphraseStrength.kt deleted file mode 100644 index ea8b02d93f..0000000000 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/CustomRecoveryPassphraseStrength.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.enterprise.api - -/** - * Strength tier surfaced to the user under a custom recovery passphrase field. - * The eight tiers mirror iOS `PasswordStrengthThreshold` 1:1 so both clients label a given - * passphrase identically; each maps to its own label and time-to-crack description in the UI. - */ -enum class CustomRecoveryPassphraseStrength { - Garbage, - Weak, - Moderate, - Okay, - Strong, - VeryStrong, - UltraStrong, - Mega, -} - -/** - * Result of estimating a custom recovery passphrase's strength. The estimation algorithm itself - * is an enterprise capability ([EnterpriseService.estimateCustomRecoveryPassphraseStrength]); FOSS - * builds never produce one. - */ -data class CustomRecoveryPassphraseStrengthResult( - val strength: CustomRecoveryPassphraseStrength, - /** Fill amount for the progress bar. Always in `0f..1f`. */ - val score: Float, -) diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index ce56a5dc80..92d8b9b646 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -41,22 +41,6 @@ interface EnterpriseService { */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? - /** - * Whether the user is allowed to set a custom recovery passphrase. - * Enterprise builds gate this in addition to the homeserver's .well-known `customRecoveryPassphraseRequirements`; - * when false the setup flow falls back to the auto-generated key path even if the homeserver - * advertises a spec. - */ - fun isCustomRecoveryPassphraseEnabled(): Boolean - - /** - * Estimate the strength of a user-typed custom recovery [passphrase]. - * The estimation algorithm is an enterprise capability: FOSS builds return null, so callers - * should treat null as "no indicator to show". Callers are responsible for not calling this with - * an empty passphrase if they want the indicator hidden while the field is empty. - */ - fun estimateCustomRecoveryPassphraseStrength(passphrase: String): CustomRecoveryPassphraseStrengthResult? - companion object { const val ANY_ACCOUNT_PROVIDER = "*" } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index caa2eb291f..6e3ed5d3cc 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -13,7 +13,6 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl -import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow @@ -46,8 +45,4 @@ class DefaultEnterpriseService : EnterpriseService { } override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null - - override fun isCustomRecoveryPassphraseEnabled(): Boolean = false - - override fun estimateCustomRecoveryPassphraseStrength(passphrase: String): CustomRecoveryPassphraseStrengthResult? = null } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index fb474a96e5..805c75be6a 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -11,7 +11,6 @@ package io.element.android.features.enterprise.test import androidx.compose.ui.graphics.Color import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl -import io.element.android.features.enterprise.api.CustomRecoveryPassphraseStrengthResult import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.tests.testutils.lambda.lambdaError @@ -32,8 +31,6 @@ class FakeEnterpriseService( private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() }, - private val isCustomRecoveryPassphraseEnabledResult: Boolean = false, - private val estimateCustomRecoveryPassphraseStrengthResult: (String) -> CustomRecoveryPassphraseStrengthResult? = { null }, ) : EnterpriseService { private val brandColorState = MutableStateFlow(initialBrandColor) private val semanticColorsState = MutableStateFlow(initialSemanticColors) @@ -82,9 +79,4 @@ class FakeEnterpriseService( override fun getNoisyNotificationChannelId(sessionId: SessionId): String? { return getNoisyNotificationChannelIdResult(sessionId) } - - override fun isCustomRecoveryPassphraseEnabled(): Boolean = isCustomRecoveryPassphraseEnabledResult - - override fun estimateCustomRecoveryPassphraseStrength(passphrase: String): CustomRecoveryPassphraseStrengthResult? = - estimateCustomRecoveryPassphraseStrengthResult(passphrase) } From 8479c8346e50664366cb52166ccfb5accd28eaa5 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 9 Jun 2026 10:03:39 -0700 Subject: [PATCH 11/23] Add bringIntoViewOnImeVisible modifier and adopt it on the recovery key screen Extract the IME-aware bring-into-view logic into a reusable designsystem modifier and use it in the enter recovery key screen, replacing the inline BringIntoViewRequester wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../enter/SecureBackupEnterRecoveryKeyView.kt | 34 +------------ .../modifiers/BringIntoViewOnImeVisible.kt | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt index 8b2bc4dc90..34fca33e96 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt @@ -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, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt new file mode 100644 index 0000000000..155172c96f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt @@ -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 } +} From b8daada2a3f630978411ce9d9dec7075e42cad78 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 9 Jun 2026 10:03:49 -0700 Subject: [PATCH 12/23] Fix SnackbarDispatcher dropping messages for hosts that subscribe after a post Publish the current queue head via a StateFlow so any host that subscribes after a message is posted (e.g. a screen recomposing as a flow pops back to it) still observes it. The previous mutex-gated flow delivered each message to a single parked collector, which could starve the host actually on screen and drop the snackbar. Adds a regression test for the late-subscriber case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../utils/snackbar/SnackbarDispatcher.kt | 38 +++++++++---------- .../utils/snackbar/SnackbarDispatcherTest.kt | 21 ++++++++++ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt index 56bb070228..566c92d5cf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt @@ -18,39 +18,35 @@ import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource import io.element.android.libraries.designsystem.theme.components.Snackbar import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow /** * 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 { - private val queueMutex = Mutex() private val snackBarMessageQueue = ArrayDeque() - val snackbarMessage: Flow = flow { - while (currentCoroutineContext().isActive) { - queueMutex.lock() - emit(snackBarMessageQueue.firstOrNull()) - } - } + private val currentMessage = MutableStateFlow(null) + val snackbarMessage: Flow = currentMessage.asStateFlow() + + @Synchronized fun post(message: SnackbarMessage) { - if (snackBarMessageQueue.isEmpty()) { - snackBarMessageQueue.add(message) - if (queueMutex.isLocked) queueMutex.unlock() - } else { - snackBarMessageQueue.add(message) - } + snackBarMessageQueue.add(message) + currentMessage.value = snackBarMessageQueue.firstOrNull() } + @Synchronized fun clear() { - if (snackBarMessageQueue.isNotEmpty()) { - snackBarMessageQueue.removeFirstOrNull() - if (queueMutex.isLocked) queueMutex.unlock() - } + snackBarMessageQueue.removeFirstOrNull() + currentMessage.value = snackBarMessageQueue.firstOrNull() } } diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt index 91c32b1da0..fe17aa0b6c 100644 --- a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt @@ -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 fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest { val snackbarDispatcher = SnackbarDispatcher() From 2a66df7b8a602ec5efe4d614cacac718c26338ca Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 9 Jun 2026 12:15:21 -0700 Subject: [PATCH 13/23] Avoid describing Pro-build specifics in the recovery setup entry point doc Genericise the SecureBackupSetupEntryPoint KDoc so the public repo no longer spells out the enterprise custom recovery passphrase behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/securebackup/api/SecureBackupSetupEntryPoint.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt index ca837d9cf8..be9bcdad71 100644 --- a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt +++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt @@ -15,9 +15,8 @@ import io.element.android.libraries.architecture.NodeInputs /** * Entry point for the recovery-key setup screen. * - * The default (FOSS) implementation builds the standard auto-generated-key setup node. Enterprise - * builds replace this binding to vend a richer setup node (e.g. a homeserver-advertised custom - * recovery passphrase) while falling back to the standard node when no custom spec applies. + * The default implementation builds the standard auto-generated-key setup node. The binding can be + * replaced to provide an alternative setup node. */ interface SecureBackupSetupEntryPoint : FeatureEntryPoint { data class Inputs(val isChangeRecoveryKeyUserStory: Boolean) : NodeInputs From a205d03b43e4f4c9114b69b1d91c649da9874c9d Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 9 Jun 2026 14:42:38 -0700 Subject: [PATCH 14/23] Floor the custom recovery passphrase minimum at 1 instead of ignoring the spec When the homeserver advertises custom_recovery_passphrase_settings, always run the custom passphrase flow, flooring min_character_count at 1 (covering missing, zero, and negative values) so the derived passphrase can never be empty. Any stronger minimum stays the operator's choice. Replaces the previous behaviour of dropping the spec and falling back to the auto-generated key. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../libraries/wellknown/impl/Mapper.kt | 26 +++++++++++++------ .../DefaultSessionWellknownRetrieverTest.kt | 24 ++++++++++++----- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt index 61899578d7..2353017be8 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt @@ -15,6 +15,13 @@ 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( registrationHelperUrl = registrationHelperUrl, enforceElementPro = enforceElementPro, @@ -25,14 +32,17 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown( customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements?.toPublic(), ) -private fun InternalCustomRecoveryPassphraseRequirements.toPublic(): CustomRecoveryPassphraseRequirements? { - val min = minCharacterCount ?: run { - Timber.tag(loggerTag.value).w("custom_recovery_passphrase_settings missing min_character_count; ignoring spec") - return null - } - if (min <= 0) { - Timber.tag(loggerTag.value).w("custom_recovery_passphrase_settings.min_character_count must be > 0; ignoring spec") - return null +private fun InternalCustomRecoveryPassphraseRequirements.toPublic(): CustomRecoveryPassphraseRequirements { + // 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_settings.min_character_count was %s; flooring to %d", + minCharacterCount, + min, + ) } return CustomRecoveryPassphraseRequirements(minCharacterCount = min) } diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt index 68d4d818c2..bdd25aa80b 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt @@ -137,7 +137,7 @@ class DefaultSessionWellknownRetrieverTest { } @Test - fun `get element wellknown with custom recovery passphrase requirements missing min character count maps to null`() = runTest { + fun `get element wellknown with custom recovery passphrase requirements missing min character count floors to 1`() = runTest { val sut = createDefaultSessionWellknownRetriever( getUrlLambda = { Result.success( @@ -148,12 +148,16 @@ class DefaultSessionWellknownRetrieverTest { }, ) assertThat(sut.getElementWellKnown()).isEqualTo( - WellknownRetrieverResult.Success(anElementWellKnown()) + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 1) + ) + ) ) } @Test - fun `get element wellknown with non-positive min character count maps to null`() = runTest { + fun `get element wellknown with zero min character count floors to 1`() = runTest { val sut = createDefaultSessionWellknownRetriever( getUrlLambda = { Result.success( @@ -166,12 +170,16 @@ class DefaultSessionWellknownRetrieverTest { }, ) assertThat(sut.getElementWellKnown()).isEqualTo( - WellknownRetrieverResult.Success(anElementWellKnown()) + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 1) + ) + ) ) } @Test - fun `get element wellknown with negative min character count maps to null`() = runTest { + fun `get element wellknown with negative min character count floors to 1`() = runTest { val sut = createDefaultSessionWellknownRetriever( getUrlLambda = { Result.success( @@ -184,7 +192,11 @@ class DefaultSessionWellknownRetrieverTest { }, ) assertThat(sut.getElementWellKnown()).isEqualTo( - WellknownRetrieverResult.Success(anElementWellKnown()) + WellknownRetrieverResult.Success( + anElementWellKnown( + customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 1) + ) + ) ) } From 4f2c2b773a8aaf3b838d8fc43a9de8408239a35c Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 9 Jun 2026 15:07:47 -0700 Subject: [PATCH 15/23] Move custom recovery passphrase strings to FOSS for iOS parity Add the pro_screen_recovery_key_mode_* and a11y_recovery_key_* strings to the securebackup module's temporary.xml so they flow through Localazy for translation, matching element-x-ios#5684, and bump the enterprise submodule to the commit that references these FOSS strings and drops its own copy. Co-Authored-By: Claude Opus 4.8 (1M context) --- enterprise | 2 +- .../impl/src/main/res/values/temporary.xml | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 features/securebackup/impl/src/main/res/values/temporary.xml diff --git a/enterprise b/enterprise index 6781da90aa..b5faab58b9 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 6781da90aae61cf77dcdbc543e18d76411d578b4 +Subproject commit b5faab58b90f46a7c3000f216da91a558b65980e diff --git a/features/securebackup/impl/src/main/res/values/temporary.xml b/features/securebackup/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..272343aa99 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values/temporary.xml @@ -0,0 +1,37 @@ + + + Enter a recovery key + Choose a recovery key that you can memorize. + Confirm your recovery key + Enter your recovery key again. + Minimum %1$s characters + Finish setup + Your backup is now fully set up + You can use your recovery key to confirm new devices or restore your encrypted chats if you lose access to all your devices. + To change your recovery key, go to Settings → Encryption → Backup + Strength + Garbage + Weak + Moderate + Okay + Strong + Very Strong + Super Strong + Ideal + Cracking would take roughly milliseconds. + Cracking would take roughly minutes to months. + Cracking would take roughly years. + Cracking would take roughly hundreds of years. + Cracking would take roughly billions of years. + Cracking would take roughly trillions of years. + Cracking would take roughly quadrillions of years. + Cracking would take roughly quintillions of years. + The recovery key you entered doesn\'t match + Passphrase strength: %1$s + Loading recovery key requirements + From 1df91ca8d54a166bc01577d39ebe456aa6304da0 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 9 Jun 2026 15:13:56 -0700 Subject: [PATCH 16/23] Drop the SecureBackupSetupEntryPoint KDoc to match sibling entry points No other FeatureEntryPoint interface carries KDoc, and the genericised comment only restated what FeatureEntryPoint + DI already imply. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../securebackup/api/SecureBackupSetupEntryPoint.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt index be9bcdad71..f674dac690 100644 --- a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt +++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupSetupEntryPoint.kt @@ -12,12 +12,6 @@ import com.bumble.appyx.core.node.Node import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs -/** - * Entry point for the recovery-key setup screen. - * - * The default implementation builds the standard auto-generated-key setup node. The binding can be - * replaced to provide an alternative setup node. - */ interface SecureBackupSetupEntryPoint : FeatureEntryPoint { data class Inputs(val isChangeRecoveryKeyUserStory: Boolean) : NodeInputs From 259ff4489740a717b1fb1a5ee22e75ff3b964f0b Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Tue, 9 Jun 2026 16:29:21 -0700 Subject: [PATCH 17/23] Bump enterprise submodule: adopt review suggestions on the custom recovery passphrase presenter Co-Authored-By: Claude Opus 4.8 (1M context) --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index b5faab58b9..22d08bcc99 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit b5faab58b90f46a7c3000f216da91a558b65980e +Subproject commit 22d08bcc99a72b3ce7156a18831d0aeee5cbf519 From cc9d7876027a48f2aa288fc5645dac1e8a293eb5 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 11 Jun 2026 14:39:58 -0700 Subject: [PATCH 18/23] Collapse the recovery passphrase strength labels to five tiers Adopt the design feedback from element-meta#3228: drop the time-to-crack hint strings and reduce the strength labels to Very weak / Weak / Moderate / Strong / Very strong. Bumps the enterprise submodule to the matching indicator change. Co-Authored-By: Claude Opus 4.8 (1M context) --- enterprise | 2 +- .../impl/src/main/res/values/temporary.xml | 17 +++-------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/enterprise b/enterprise index 22d08bcc99..11248b7ba4 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 22d08bcc99a72b3ce7156a18831d0aeee5cbf519 +Subproject commit 11248b7ba47a0e8ba32be9d094c22a3e4defe94b diff --git a/features/securebackup/impl/src/main/res/values/temporary.xml b/features/securebackup/impl/src/main/res/values/temporary.xml index 272343aa99..7bac07c172 100644 --- a/features/securebackup/impl/src/main/res/values/temporary.xml +++ b/features/securebackup/impl/src/main/res/values/temporary.xml @@ -9,28 +9,17 @@ Choose a recovery key that you can memorize. Confirm your recovery key Enter your recovery key again. - Minimum %1$s characters + Minimum %1$s characters. Do not use your account password Finish setup Your backup is now fully set up You can use your recovery key to confirm new devices or restore your encrypted chats if you lose access to all your devices. To change your recovery key, go to Settings → Encryption → Backup Strength - Garbage + Very weak Weak Moderate - Okay Strong - Very Strong - Super Strong - Ideal - Cracking would take roughly milliseconds. - Cracking would take roughly minutes to months. - Cracking would take roughly years. - Cracking would take roughly hundreds of years. - Cracking would take roughly billions of years. - Cracking would take roughly trillions of years. - Cracking would take roughly quadrillions of years. - Cracking would take roughly quintillions of years. + Very strong The recovery key you entered doesn\'t match Passphrase strength: %1$s Loading recovery key requirements From fdac92a9ea5c6d95c32c0f14717d5a70fae47ad7 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 11 Jun 2026 15:03:24 -0700 Subject: [PATCH 19/23] Rename the custom recovery passphrase well-known to match the merged schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged element-enterprise#261 well-known schema advertises the feature under custom_recovery_passphrase (no _settings/_requirements suffix), so the parser previously looking for custom_recovery_passphrase_settings would never activate the flow. Update the @SerialName and drop the narrow "Requirements" suffix from the type — the block is now general, extensible feature settings — to mirror the .pkl CustomRecoveryPassphrase class. Bumps the enterprise submodule to the matching consumer change. Co-Authored-By: Claude Opus 4.8 (1M context) --- enterprise | 2 +- ...rements.kt => CustomRecoveryPassphrase.kt} | 8 ++--- .../wellknown/api/ElementWellKnown.kt | 2 +- .../impl/InternalElementWellKnown.kt | 8 ++--- .../libraries/wellknown/impl/Mapper.kt | 10 +++--- ...est.kt => CustomRecoveryPassphraseTest.kt} | 10 +++--- .../DefaultSessionWellknownRetrieverTest.kt | 32 +++++++++---------- .../features/wellknown/test/Fixtures.kt | 10 +++--- 8 files changed, 41 insertions(+), 41 deletions(-) rename libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/{CustomRecoveryPassphraseRequirements.kt => CustomRecoveryPassphrase.kt} (67%) rename libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/{CustomRecoveryPassphraseRequirementsTest.kt => CustomRecoveryPassphraseTest.kt} (55%) diff --git a/enterprise b/enterprise index 11248b7ba4..cc23736aca 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 11248b7ba47a0e8ba32be9d094c22a3e4defe94b +Subproject commit cc23736aca2428dfeed246fdbb608fd2da4869b9 diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt similarity index 67% rename from libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt rename to libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt index dc6241b0a9..c7bc602dde 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirements.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphrase.kt @@ -8,11 +8,11 @@ package io.element.android.libraries.wellknown.api /** - * Server-driven requirements for a user-chosen recovery passphrase. Today the only rule - * is a minimum character count; additional rules can be added here as the well-known - * schema (`custom_recovery_passphrase_settings`) grows. + * 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 CustomRecoveryPassphraseRequirements( +data class CustomRecoveryPassphrase( val minCharacterCount: Int, ) { /** True when [input] meets every active rule. */ diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt index ac050ba94a..8f2c07ab68 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt @@ -15,5 +15,5 @@ data class ElementWellKnown( val brandColor: String?, val notificationSound: String?, val identityProviderAppScheme: String?, - val customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements?, + val customRecoveryPassphrase: CustomRecoveryPassphrase?, ) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt index 803e498eda..cd0f39c77f 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt @@ -21,7 +21,7 @@ import kotlinx.serialization.Serializable * "brand_color": "#FF0000", * "notification_sound": "ring.flac", * "idp_app_scheme": "io.element.app", - * "custom_recovery_passphrase_settings": { + * "custom_recovery_passphrase": { * "min_character_count": 8 * } * } @@ -42,12 +42,12 @@ data class InternalElementWellKnown( val notificationSound: String? = null, @SerialName("idp_app_scheme") val identityProviderAppScheme: String? = null, - @SerialName("custom_recovery_passphrase_settings") - val customRecoveryPassphraseRequirements: InternalCustomRecoveryPassphraseRequirements? = null, + @SerialName("custom_recovery_passphrase") + val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null, ) @Serializable -data class InternalCustomRecoveryPassphraseRequirements( +data class InternalCustomRecoveryPassphrase( @SerialName("min_character_count") val minCharacterCount: Int? = null, ) diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt index 2353017be8..c8760881d4 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt @@ -9,7 +9,7 @@ package io.element.android.libraries.wellknown.impl import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase import io.element.android.libraries.wellknown.api.ElementWellKnown import timber.log.Timber @@ -29,20 +29,20 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown( brandColor = brandColor, notificationSound = notificationSound, identityProviderAppScheme = identityProviderAppScheme, - customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements?.toPublic(), + customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(), ) -private fun InternalCustomRecoveryPassphraseRequirements.toPublic(): CustomRecoveryPassphraseRequirements { +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_settings.min_character_count was %s; flooring to %d", + "custom_recovery_passphrase.min_character_count was %s; flooring to %d", minCharacterCount, min, ) } - return CustomRecoveryPassphraseRequirements(minCharacterCount = min) + return CustomRecoveryPassphrase(minCharacterCount = min) } diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt similarity index 55% rename from libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt rename to libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt index c8f10fe2ca..d4e7ac112e 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseRequirementsTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/api/CustomRecoveryPassphraseTest.kt @@ -10,24 +10,24 @@ package io.element.android.libraries.wellknown.api import com.google.common.truth.Truth.assertThat import org.junit.Test -class CustomRecoveryPassphraseRequirementsTest { +class CustomRecoveryPassphraseTest { @Test fun `empty input never satisfies a positive minimum`() { - assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 1).isSatisfiedBy("")).isFalse() + assertThat(CustomRecoveryPassphrase(minCharacterCount = 1).isSatisfiedBy("")).isFalse() } @Test fun `input shorter than minimum is not satisfied`() { - assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse() + assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse() } @Test fun `input exactly at minimum is satisfied`() { - assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue() + assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue() } @Test fun `input longer than minimum is satisfied`() { - assertThat(CustomRecoveryPassphraseRequirements(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue() + assertThat(CustomRecoveryPassphrase(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue() } } diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt index bdd25aa80b..a64d156f6e 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt @@ -19,7 +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.FakeMatrixClient import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.toolbox.api.systemclock.SystemClock @@ -52,7 +52,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = null, notificationSound = null, identityProviderAppScheme = null, - customRecoveryPassphraseRequirements = null, + customRecoveryPassphrase = null, ) ) ) @@ -78,7 +78,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", - customRecoveryPassphraseRequirements = null, + customRecoveryPassphrase = null, ) ) ) @@ -108,19 +108,19 @@ class DefaultSessionWellknownRetrieverTest { brandColor = null, notificationSound = null, identityProviderAppScheme = null, - customRecoveryPassphraseRequirements = null, + customRecoveryPassphrase = null, ) ) ) } @Test - fun `get element wellknown with custom recovery passphrase requirements`() = runTest { + fun `get element wellknown with custom recovery passphrase settings`() = runTest { val sut = createDefaultSessionWellknownRetriever( getUrlLambda = { Result.success( """{ - "custom_recovery_passphrase_settings": { + "custom_recovery_passphrase": { "min_character_count": 8 } }""".trimIndent().toByteArray() @@ -130,19 +130,19 @@ class DefaultSessionWellknownRetrieverTest { assertThat(sut.getElementWellKnown()).isEqualTo( WellknownRetrieverResult.Success( anElementWellKnown( - customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 8) + customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8) ) ) ) } @Test - fun `get element wellknown with custom recovery passphrase requirements missing min character count floors to 1`() = runTest { + 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_settings": {} + "custom_recovery_passphrase": {} }""".trimIndent().toByteArray() ) }, @@ -150,7 +150,7 @@ class DefaultSessionWellknownRetrieverTest { assertThat(sut.getElementWellKnown()).isEqualTo( WellknownRetrieverResult.Success( anElementWellKnown( - customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 1) + customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1) ) ) ) @@ -162,7 +162,7 @@ class DefaultSessionWellknownRetrieverTest { getUrlLambda = { Result.success( """{ - "custom_recovery_passphrase_settings": { + "custom_recovery_passphrase": { "min_character_count": 0 } }""".trimIndent().toByteArray() @@ -172,7 +172,7 @@ class DefaultSessionWellknownRetrieverTest { assertThat(sut.getElementWellKnown()).isEqualTo( WellknownRetrieverResult.Success( anElementWellKnown( - customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 1) + customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1) ) ) ) @@ -184,7 +184,7 @@ class DefaultSessionWellknownRetrieverTest { getUrlLambda = { Result.success( """{ - "custom_recovery_passphrase_settings": { + "custom_recovery_passphrase": { "min_character_count": -5 } }""".trimIndent().toByteArray() @@ -194,7 +194,7 @@ class DefaultSessionWellknownRetrieverTest { assertThat(sut.getElementWellKnown()).isEqualTo( WellknownRetrieverResult.Success( anElementWellKnown( - customRecoveryPassphraseRequirements = CustomRecoveryPassphraseRequirements(minCharacterCount = 1) + customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1) ) ) ) @@ -247,7 +247,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", - customRecoveryPassphraseRequirements = null, + customRecoveryPassphrase = null, ) ) ) @@ -301,7 +301,7 @@ class DefaultSessionWellknownRetrieverTest { brandColor = "#FF0000", notificationSound = "a_notification_sound.flac", identityProviderAppScheme = "an_app_scheme", - customRecoveryPassphraseRequirements = null, + customRecoveryPassphrase = null, ) ) ) diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt index 1ed69207ba..51b06f7226 100644 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt @@ -8,7 +8,7 @@ package io.element.android.features.wellknown.test -import io.element.android.libraries.wellknown.api.CustomRecoveryPassphraseRequirements +import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase import io.element.android.libraries.wellknown.api.ElementWellKnown fun anElementWellKnown( @@ -18,7 +18,7 @@ fun anElementWellKnown( brandColor: String? = null, notificationSound: String? = null, identityProviderAppScheme: String? = null, - customRecoveryPassphraseRequirements: CustomRecoveryPassphraseRequirements? = null, + customRecoveryPassphrase: CustomRecoveryPassphrase? = null, ) = ElementWellKnown( registrationHelperUrl = registrationHelperUrl, enforceElementPro = enforceElementPro, @@ -26,11 +26,11 @@ fun anElementWellKnown( brandColor = brandColor, notificationSound = notificationSound, identityProviderAppScheme = identityProviderAppScheme, - customRecoveryPassphraseRequirements = customRecoveryPassphraseRequirements, + customRecoveryPassphrase = customRecoveryPassphrase, ) -fun aCustomRecoveryPassphraseRequirements( +fun aCustomRecoveryPassphrase( minCharacterCount: Int = 8, -) = CustomRecoveryPassphraseRequirements( +) = CustomRecoveryPassphrase( minCharacterCount = minCharacterCount, ) From e90c91d4468cd62a0e8592f92ea7ba514723ea94 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 11 Jun 2026 16:04:25 -0700 Subject: [PATCH 20/23] Add a node-builder test for DefaultSecureBackupSetupEntryPoint The new entry point had no test, leaving its createNode body (and the api Inputs data class) uncovered and failing codecov/patch. Mirror the existing Default*EntryPointTest convention to exercise the builder. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DefaultSecureBackupSetupEntryPointTest.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt new file mode 100644 index 0000000000..e37dab748f --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt @@ -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)) + } +} From 44d4ed1e4674295714911d32dab3b78a023bf6b4 Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 11 Jun 2026 16:16:17 -0700 Subject: [PATCH 21/23] Bump enterprise submodule: use a Compound token for the Moderate strength colour Co-Authored-By: Claude Opus 4.8 (1M context) --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index cc23736aca..f5cc2f6f8e 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit cc23736aca2428dfeed246fdbb608fd2da4869b9 +Subproject commit f5cc2f6f8e85ef8f09187b039c9561b9ecca8fe6 From edf47f367d84845d290af57ae59f56f5375413fd Mon Sep 17 00:00:00 2001 From: Jenna Vassar Date: Thu, 11 Jun 2026 16:37:48 -0700 Subject: [PATCH 22/23] Bump enterprise submodule: split passphrase symbol entropy factors Co-Authored-By: Claude Opus 4.8 (1M context) --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index f5cc2f6f8e..3e04d8aa06 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit f5cc2f6f8e85ef8f09187b039c9561b9ecca8fe6 +Subproject commit 3e04d8aa069904e5c21384edf651ee76c13ab0c0 From 72e9371fa30a1de4c0f8963176f0e4d16f8a1632 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 17 Jun 2026 10:04:24 +0200 Subject: [PATCH 23/23] Custom recovery key: import strings from Localazy --- enterprise | 2 +- .../impl/src/main/res/values/localazy.xml | 18 +++++++++++++ .../impl/src/main/res/values/temporary.xml | 26 ------------------- tools/localazy/config.json | 1 + 4 files changed, 20 insertions(+), 27 deletions(-) delete mode 100644 features/securebackup/impl/src/main/res/values/temporary.xml diff --git a/enterprise b/enterprise index 3e04d8aa06..0a17f4b5c9 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 3e04d8aa069904e5c21384edf651ee76c13ab0c0 +Subproject commit 0a17f4b5c9b5d7bc450eae6f1c4c082b37bca36e diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml index e0fc2d37e7..65266060a1 100644 --- a/features/securebackup/impl/src/main/res/values/localazy.xml +++ b/features/securebackup/impl/src/main/res/values/localazy.xml @@ -20,6 +20,24 @@ "Follow the instructions to create a new recovery key" "Save your new recovery key in a password manager or encrypted note" "Reset the encryption for your account using another device" + "The recovery key you entered doesn\'t match" + "Finish setup" + "Enter your recovery key again." + "Confirm your recovery key" + "Minimum %1$s characters. Do not use your account password" + "Strength" + "Passphrase strength: %1$s" + "Moderate" + "Strong" + "Very strong" + "Very weak" + "Weak" + "Choose a recovery key that you can memorize." + "Enter a recovery key" + "Loading recovery key requirements" + "To change your recovery key, go to Settings → Encryption → Backup" + "You can use your recovery key to confirm new devices or restore your encrypted chats if you lose access to all your devices." + "Your backup is now fully set up" "Continue reset" "Your account details, contacts, preferences, and chat list will be kept" "You will lose any message history that’s stored only on the server" diff --git a/features/securebackup/impl/src/main/res/values/temporary.xml b/features/securebackup/impl/src/main/res/values/temporary.xml deleted file mode 100644 index 7bac07c172..0000000000 --- a/features/securebackup/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - Enter a recovery key - Choose a recovery key that you can memorize. - Confirm your recovery key - Enter your recovery key again. - Minimum %1$s characters. Do not use your account password - Finish setup - Your backup is now fully set up - You can use your recovery key to confirm new devices or restore your encrypted chats if you lose access to all your devices. - To change your recovery key, go to Settings → Encryption → Backup - Strength - Very weak - Weak - Moderate - Strong - Very strong - The recovery key you entered doesn\'t match - Passphrase strength: %1$s - Loading recovery key requirements - diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 6ea495439e..e125ffbe6f 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -315,6 +315,7 @@ "screen_chat_backup_.*", "screen_key_backup_disable_.*", "screen_recovery_key_.*", + "screen\\.custom_recovery_key\\..*", "screen_create_new_recovery_key_.*", "screen_encryption_reset.*", "screen_reset_encryption.*",