From 10b0221e98e7298d1d75f4644dd7688e9a6e326f Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 13 May 2026 12:45:06 -0400 Subject: [PATCH] Add support for a better AEP character set. --- .../v2/ui/subscription/EnterKeyScreen.kt | 3 +- .../InternalBackupPlaygroundFragment.kt | 2 +- .../restore/BackupKeyVisualTransformation.kt | 5 +- .../ui/restore/EnterBackupKeyFragment.kt | 2 +- .../ui/restore/EnterBackupKeyScreen.kt | 8 +- .../ui/restore/EnterBackupKeyViewModel.kt | 9 +- .../PostRegistrationEnterBackupKeyFragment.kt | 2 +- ...PostRegistrationEnterBackupKeyViewModel.kt | 9 +- core/models-jvm/build.gradle.kts | 3 + .../signal/core/models/AccountEntropyPool.kt | 66 +++++++- .../core/models/AccountEntropyPoolTest.kt | 149 ++++++++++++++++++ .../screens/aepentry/EnterAepScreen.kt | 9 +- .../aepentry/EnterAepScreenEventHandler.kt | 5 +- .../screens/aepentry/EnterAepState.kt | 3 + 14 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 core/models-jvm/src/test/java/org/signal/core/models/AccountEntropyPoolTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt index 7ccdb9aef3..3edfe969b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/EnterKeyScreen.kt @@ -93,7 +93,8 @@ fun EnterKeyScreen( val updateEnteredBackupKey = { input: String -> enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase() - isBackupKeyValid = enteredBackupKey == backupKey + val normalized = AccountEntropyPool.formatForStorage(enteredBackupKey) + isBackupKeyValid = normalized.equals(AccountEntropyPool.formatForStorage(backupKey), ignoreCase = true) showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index de92185f8a..91d95a3bc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -503,7 +503,7 @@ fun Screen( text = "Copy Account Entropy Pool (AEP)", label = "Copies the Account Entropy Pool (AEP) to the clipboard, which is labeled as the \"Backup Key\" in the designs.", onClick = { - Util.copyToClipboard(context, SignalStore.account.accountEntropyPool.value) + Util.copyToClipboard(context, SignalStore.account.accountEntropyPool.displayValue) Toast.makeText(context, "Copied!", Toast.LENGTH_SHORT).show() } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/BackupKeyVisualTransformation.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/BackupKeyVisualTransformation.kt index c57fa9bf97..5f6b4eef66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/BackupKeyVisualTransformation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/BackupKeyVisualTransformation.kt @@ -11,14 +11,15 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation /** - * Visual formatter for backup keys. + * Visual formatter for backup keys. Preserves whatever the user typed verbatim (no character + * swapping) and just groups characters with spaces. * * @param chunkSize character count per group */ class BackupKeyVisualTransformation(private val chunkSize: Int) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { var output = "" - for ((i, c) in text.withIndex()) { + for ((i, c) in text.text.withIndex()) { output += c if (i % chunkSize == chunkSize - 1) { output += " " diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyFragment.kt index 89844b1533..40e7bd50df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyFragment.kt @@ -96,7 +96,7 @@ class EnterBackupKeyFragment : ComposeFragment() { EnterBackupKeyScreen( isDisplayedDuringManualRestore = true, - backupKey = viewModel.backupKey, + enteredText = viewModel.enteredText, inProgress = sharedState.inProgress, isBackupKeyValid = state.backupKeyValid, chunkLength = state.chunkLength, diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt index 994aa8646a..da3bd084a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyScreen.kt @@ -68,7 +68,7 @@ import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen @Composable fun EnterBackupKeyScreen( isDisplayedDuringManualRestore: Boolean, - backupKey: String, + enteredText: String, inProgress: Boolean, isBackupKeyValid: Boolean, chunkLength: Int, @@ -140,7 +140,7 @@ fun EnterBackupKeyScreen( val autoFillHelper = backupKeyAutoFillHelper { onBackupKeyChanged(it) } TextField( - value = backupKey, + value = enteredText, onValueChange = { onBackupKeyChanged(it) autoFillHelper.onValueChanged(it) @@ -222,7 +222,7 @@ private fun AccountEntropyPoolVerification.AEPValidationError.ValidationErrorMes private fun EnterBackupKeyScreenPreview() { Previews.Preview { EnterBackupKeyScreen( - backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t".uppercase(), + enteredText = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t".uppercase(), isBackupKeyValid = true, inProgress = false, chunkLength = 4, @@ -237,7 +237,7 @@ private fun EnterBackupKeyScreenPreview() { private fun EnterBackupKeyScreenErrorPreview() { Previews.Preview { EnterBackupKeyScreen( - backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t".uppercase(), + enteredText = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t".uppercase(), isBackupKeyValid = true, inProgress = false, chunkLength = 4, diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyViewModel.kt index bcea6a226e..99c092f066 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyViewModel.kt @@ -41,14 +41,21 @@ class EnterBackupKeyViewModel : ViewModel() { private val verifyGeneration = AtomicInteger(0) + /** Raw user-typed text (illegal chars stripped, length-capped). Bound to the TextField so #/= stay visible. */ + var enteredText by mutableStateOf("") + private set + + /** Storage-normalized lowercase form of [enteredText], used for parseOrNull and submit. */ var backupKey by mutableStateOf("") private set val state: StateFlow = store fun updateBackupKey(key: String) { - val newKey = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16).lowercase() + val newEnteredText = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16) + val newKey = AccountEntropyPool.formatForStorage(newEnteredText).lowercase() val changed = newKey != backupKey + enteredText = newEnteredText backupKey = newKey store.update { val (isValid, updatedError) = AccountEntropyPoolVerification.verifyAEP( diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyFragment.kt index 853e68df1c..07f412e63a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyFragment.kt @@ -65,7 +65,7 @@ class PostRegistrationEnterBackupKeyFragment : ComposeFragment() { EnterBackupKeyScreen( isDisplayedDuringManualRestore = false, - backupKey = viewModel.backupKey, + enteredText = viewModel.enteredText, isBackupKeyValid = state.backupKeyValid, inProgress = state.inProgress, chunkLength = 4, diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyViewModel.kt index 1c30dbee97..186243e5e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/enterbackupkey/PostRegistrationEnterBackupKeyViewModel.kt @@ -32,14 +32,21 @@ class PostRegistrationEnterBackupKeyViewModel : ViewModel() { PostRegistrationEnterBackupKeyState() ) + /** Raw user-typed text (illegal chars stripped, length-capped). Bound to the TextField so #/= stay visible. */ + var enteredText by mutableStateOf("") + private set + + /** Storage-normalized lowercase form of [enteredText], used for parseOrNull and submit. */ var backupKey by mutableStateOf("") private set val state: StateFlow = store fun updateBackupKey(key: String) { - val newKey = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16).lowercase() + val newEnteredText = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16) + val newKey = AccountEntropyPool.formatForStorage(newEnteredText).lowercase() val changed = newKey != backupKey + enteredText = newEnteredText backupKey = newKey store.update { val (isValid, updatedError) = AccountEntropyPoolVerification.verifyAEP( diff --git a/core/models-jvm/build.gradle.kts b/core/models-jvm/build.gradle.kts index 6fb45baa8f..182ee2d4ef 100644 --- a/core/models-jvm/build.gradle.kts +++ b/core/models-jvm/build.gradle.kts @@ -24,4 +24,7 @@ dependencies { implementation(libs.libsignal.client) implementation(libs.square.okio) implementation(project(":core:util-jvm")) + + testImplementation(testLibs.junit.junit) + testImplementation(testLibs.assertk) } diff --git a/core/models-jvm/src/main/java/org/signal/core/models/AccountEntropyPool.kt b/core/models-jvm/src/main/java/org/signal/core/models/AccountEntropyPool.kt index d64411120b..d0d023bbcd 100644 --- a/core/models-jvm/src/main/java/org/signal/core/models/AccountEntropyPool.kt +++ b/core/models-jvm/src/main/java/org/signal/core/models/AccountEntropyPool.kt @@ -15,18 +15,40 @@ private typealias LibSignalAccountEntropyPool = org.signal.libsignal.messageback class AccountEntropyPool(value: String) { val value = value.lowercase() - val displayValue = value.uppercase() + val displayValue = formatForDisplay(value.uppercase()) companion object { - private val INVALID_CHARACTERS = Regex("[^0-9a-zA-Z]") const val LENGTH = 64 + /** + * Maps storage characters (left) to their display equivalents (right). Used to swap + * characters that are visually ambiguous in some fonts (the letter 'O' vs the digit '0') + * for unambiguous symbols when shown to users. Add new pairs here as needed; the format + * helpers and [displayValue] will pick them up automatically. + */ + val CHARACTER_DISPLAY_MAP: Map = mapOf( + 'O' to '#', + '0' to '=' + ) + + private val STORAGE_TO_DISPLAY: Map = CHARACTER_DISPLAY_MAP + private val DISPLAY_TO_STORAGE: Map = CHARACTER_DISPLAY_MAP.entries.associate { (k, v) -> v to k } + + /** Need to ship read support for at least one release before write support. */ + internal var temporarilyDisplayAsStorage: Boolean = true + + /** Storage charset (alphanumeric) plus any display characters from [CHARACTER_DISPLAY_MAP]. */ + private val INVALID_CHARACTERS: Regex = run { + val extras = CHARACTER_DISPLAY_MAP.values.joinToString("") { Regex.escape(it.toString()) } + Regex("[^0-9a-zA-Z$extras]") + } + fun generate(): AccountEntropyPool { return AccountEntropyPool(LibSignalAccountEntropyPool.generate()) } fun parseOrNull(input: String): AccountEntropyPool? { - val stripped = removeIllegalCharacters(input) + val stripped = removeIllegalCharacters(formatForStorage(input)) if (stripped.length != LENGTH) { return null } @@ -41,6 +63,44 @@ class AccountEntropyPool(value: String) { fun removeIllegalCharacters(input: String): String { return input.replace(INVALID_CHARACTERS, "") } + + /** + * Converts a storage-form AEP string into the form that should be shown to users (and stored + * in password managers) by applying [CHARACTER_DISPLAY_MAP]. Lookup is case-insensitive, so + * a lowercase storage character (e.g. 'o') is formatted the same as its uppercase counterpart + * ('O'). Characters not in the map are passed through unchanged. + */ + fun formatForDisplay(input: String): String { + // Just for one release to allow read support to propagate + if (temporarilyDisplayAsStorage) { + return formatForStorage(input) + } + + if (STORAGE_TO_DISPLAY.isEmpty()) { + return input + } + + return buildString(input.length) { + for (c in input) { + val swap = STORAGE_TO_DISPLAY[c] ?: STORAGE_TO_DISPLAY[c.uppercaseChar()] + append(swap ?: c) + } + } + } + + /** + * Converts a user-supplied (display-form) AEP string back into its storage form by reversing + * [CHARACTER_DISPLAY_MAP]. Characters not in the map are passed through unchanged. + */ + fun formatForStorage(input: String): String { + if (DISPLAY_TO_STORAGE.isEmpty()) { + return input + } + + return buildString(input.length) { + for (c in input) append(DISPLAY_TO_STORAGE[c] ?: c) + } + } } fun deriveMasterKey(): MasterKey { diff --git a/core/models-jvm/src/test/java/org/signal/core/models/AccountEntropyPoolTest.kt b/core/models-jvm/src/test/java/org/signal/core/models/AccountEntropyPoolTest.kt new file mode 100644 index 0000000000..0ae60501b5 --- /dev/null +++ b/core/models-jvm/src/test/java/org/signal/core/models/AccountEntropyPoolTest.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.models + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Test + +class AccountEntropyPoolTest { + + @Before + fun setUp() { + AccountEntropyPool.temporarilyDisplayAsStorage = false + } + + @After + fun tearDown() { + AccountEntropyPool.temporarilyDisplayAsStorage = true + } + + @Test + fun `formatForDisplay - swaps storage characters for their display equivalents`() { + assertThat(AccountEntropyPool.formatForDisplay("O")).isEqualTo("#") + assertThat(AccountEntropyPool.formatForDisplay("0")).isEqualTo("=") + } + + @Test + fun `formatForDisplay - is case-insensitive on lookup`() { + assertThat(AccountEntropyPool.formatForDisplay("o")).isEqualTo("#") + assertThat(AccountEntropyPool.formatForDisplay("O")).isEqualTo("#") + } + + @Test + fun `formatForDisplay - leaves untouched characters alone`() { + assertThat(AccountEntropyPool.formatForDisplay("ABCxyz123")).isEqualTo("ABCxyz123") + } + + @Test + fun `formatForDisplay - mixed input swaps only mapped characters`() { + assertThat(AccountEntropyPool.formatForDisplay("A0Ob1")).isEqualTo("A=#b1") + } + + @Test + fun `formatForDisplay - empty input returns empty`() { + assertThat(AccountEntropyPool.formatForDisplay("")).isEqualTo("") + } + + @Test + fun `formatForStorage - swaps display characters back to storage equivalents`() { + assertThat(AccountEntropyPool.formatForStorage("#")).isEqualTo("O") + assertThat(AccountEntropyPool.formatForStorage("=")).isEqualTo("0") + } + + @Test + fun `formatForStorage - leaves untouched characters alone`() { + assertThat(AccountEntropyPool.formatForStorage("ABCxyz123")).isEqualTo("ABCxyz123") + assertThat(AccountEntropyPool.formatForStorage("O")).isEqualTo("O") + assertThat(AccountEntropyPool.formatForStorage("0")).isEqualTo("0") + } + + @Test + fun `formatForStorage - mixed input swaps only mapped characters`() { + assertThat(AccountEntropyPool.formatForStorage("A=#b1")).isEqualTo("A0Ob1") + } + + @Test + fun `formatForStorage - empty input returns empty`() { + assertThat(AccountEntropyPool.formatForStorage("")).isEqualTo("") + } + + @Test + fun `format round trip - display then storage is identity for storage characters`() { + val storage = "ABCD0123Oabcdef" + assertThat(AccountEntropyPool.formatForStorage(AccountEntropyPool.formatForDisplay(storage))).isEqualTo(storage) + } + + @Test + fun `format round trip - storage then display is identity for display characters`() { + val display = "ABCD=123#abcdef" + assertThat(AccountEntropyPool.formatForDisplay(AccountEntropyPool.formatForStorage(display))).isEqualTo(display) + } + + @Test + fun `displayValue - applies formatting on top of uppercase`() { + val aep = AccountEntropyPool("abcdef0123456789".repeat(4)) + + assertThat(aep.value).isEqualTo("abcdef0123456789".repeat(4)) + assertThat(aep.displayValue).isEqualTo("ABCDEF=123456789".repeat(4)) + } + + @Test + fun `displayValue - formats uppercase O introduced by uppercasing storage o`() { + val aep = AccountEntropyPool("oooo0000".repeat(8)) + + assertThat(aep.value).isEqualTo("oooo0000".repeat(8)) + assertThat(aep.displayValue).isEqualTo("####====".repeat(8)) + } + + @Test + fun `parseOrNull - accepts display-form input by formatting back to storage`() { + val displayForm = "ABCDEF=123456789".repeat(4) + + val parsed = AccountEntropyPool.parseOrNull(displayForm) + + assertThat(parsed?.value).isEqualTo("abcdef0123456789".repeat(4)) + } + + @Test + fun `parseOrNull - still accepts storage-form input unchanged`() { + val storageForm = "abcdef0123456789".repeat(4) + + val parsed = AccountEntropyPool.parseOrNull(storageForm) + + assertThat(parsed?.value).isEqualTo(storageForm) + } + + @Test + fun `parseOrNull - tolerates whitespace and chunking in pasted display form`() { + val chunked = "ABCDEF=123456789 ".repeat(4).trim() + + val parsed = AccountEntropyPool.parseOrNull(chunked) + + assertThat(parsed?.value).isEqualTo("abcdef0123456789".repeat(4)) + } + + @Test + fun `removeIllegalCharacters - preserves alphanumerics and display-map characters`() { + assertThat(AccountEntropyPool.removeIllegalCharacters("aZ0 9!#=?")).isEqualTo("aZ09#=") + } + + @Test + fun `formatForDisplay - production default short-circuits to formatForStorage`() { + AccountEntropyPool.temporarilyDisplayAsStorage = true + + assertThat(AccountEntropyPool.formatForDisplay("O")).isEqualTo("O") + assertThat(AccountEntropyPool.formatForDisplay("0")).isEqualTo("0") + assertThat(AccountEntropyPool.formatForDisplay("#")).isEqualTo("O") + assertThat(AccountEntropyPool.formatForDisplay("=")).isEqualTo("0") + assertThat(AccountEntropyPool.formatForDisplay("ABCxyz123")).isEqualTo("ABCxyz123") + + val aep = AccountEntropyPool("abcdef0123456789".repeat(4)) + assertThat(aep.displayValue).isEqualTo("ABCDEF0123456789".repeat(4)) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt index 74ef97a85c..119cfdb34e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt @@ -226,7 +226,7 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents) val autoFillHelper = backupKeyAutoFillHelper { onEvent(EnterAepEvents.BackupKeyChanged(it)) } TextField( - value = state.backupKey, + value = state.enteredText, onValueChange = { onEvent(EnterAepEvents.BackupKeyChanged(it)) autoFillHelper.onValueChanged(it) @@ -302,12 +302,13 @@ private fun NextButton(state: EnterAepState, onEvent: (EnterAepEvents) -> Unit, } /** - * Visual formatter for backup keys — groups characters with spaces. + * Visual formatter for backup keys — groups characters with spaces. Preserves whatever the user + * typed verbatim (no character swapping). */ private class AepVisualTransformation(private val chunkSize: Int) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { var output = "" - for ((i, c) in text.withIndex()) { + for ((i, c) in text.text.withIndex()) { output += c if (i % chunkSize == chunkSize - 1) { output += " " @@ -355,6 +356,7 @@ private fun EnterAepScreenFilledPreview() { Previews.Preview { EnterAepScreen( state = EnterAepState( + enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", isBackupKeyValid = true ), @@ -369,6 +371,7 @@ private fun EnterAepScreenErrorPreview() { Previews.Preview { EnterAepScreen( state = EnterAepState( + enteredText = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", backupKey = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", isBackupKeyValid = false, aepValidationError = AepValidationError.Invalid diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt index 5ae398a9ee..23f8c21280 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreenEventHandler.kt @@ -18,9 +18,9 @@ object EnterAepScreenEventHandler { } private fun applyBackupKeyChanged(state: EnterAepState, key: String): EnterAepState { - val newKey = AccountEntropyPool.removeIllegalCharacters(key) + val enteredText = AccountEntropyPool.removeIllegalCharacters(key) .take(AccountEntropyPool.LENGTH + 16) - .lowercase() + val newKey = AccountEntropyPool.formatForStorage(enteredText).lowercase() val isValid = AccountEntropyPool.isFullyValid(newKey) val isShort = newKey.length < AccountEntropyPool.LENGTH @@ -44,6 +44,7 @@ object EnterAepScreenEventHandler { } return state.copy( + enteredText = enteredText, backupKey = newKey, isBackupKeyValid = isValid, aepValidationError = updatedError diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt index fb814025ef..f5912b64e7 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepState.kt @@ -8,6 +8,9 @@ package org.signal.registration.screens.aepentry import org.signal.registration.util.DebugLoggableModel data class EnterAepState( + /** The user's typed text, preserved verbatim (illegal chars stripped). Bound to the TextField so #/= stay visible as the user types them. */ + val enteredText: String = "", + /** Storage-normalized lowercase form of [enteredText], used for validation and submit. */ val backupKey: String = "", val isBackupKeyValid: Boolean = false, val aepValidationError: AepValidationError? = null,