diff --git a/app/src/androidTest/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreenTest.kt new file mode 100644 index 0000000000..8279619262 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreenTest.kt @@ -0,0 +1,149 @@ +package to.bitkit.ui.settings.backups + +import android.os.SystemClock +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onChildAt +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +@ComposeUi +class ConfirmMnemonicScreenTest { + + companion object { + private val WORDS = listOf( + "able", "baby", "cable", "dance", "eagle", "fabric", + "gadget", "habit", "ice", "jacket", "kangaroo", "label", + ) + + /** Longer than the 500 ms click debounce, so the same node can be tapped again. */ + private const val CLICK_DEBOUNCE_WAIT_MS = 600L + } + + @get:Rule + val composeTestRule = createComposeRule() + + private fun setScreen(tester: StateRestorationTester? = null) { + val content: @Composable () -> Unit = { + AppThemeSurface { + ConfirmMnemonicScreen( + uiState = BackupContract.UiState(bip39Mnemonic = WORDS.joinToString(" ")), + onContinue = {}, + onBack = {}, + ) + } + } + if (tester != null) tester.setContent(content) else composeTestRule.setContent(content) + } + + private fun tap(tag: String) { + composeTestRule.onNodeWithTag(tag).performScrollTo().performClick() + composeTestRule.waitForIdle() + } + + private fun tapChip(word: String) = tap("Word-$word") + + private fun selectedWord(number: Int) = composeTestRule.onNodeWithTag("SelectedWord-$number") + + private fun assertSelectedWord(number: Int, word: String) { + selectedWord(number).onChildAt(1).assertTextEquals(word) + } + + private fun waitForClickDebounce() = SystemClock.sleep(CLICK_DEBOUNCE_WAIT_MS) + + private fun chipOrder(): List = WORDS.sortedWith( + compareBy( + { composeTestRule.onNodeWithTag("Word-$it").fetchSemanticsNode().boundsInRoot.top }, + { composeTestRule.onNodeWithTag("Word-$it").fetchSemanticsNode().boundsInRoot.left }, + ) + ) + + private fun completeFrom(position: Int) { + WORDS.drop(position).forEach { tapChip(it) } + } + + @Test + fun tappingRedWord_clearsItAndReleasesItsChip() { + setScreen() + + tapChip(WORDS[2]) + assertSelectedWord(1, WORDS[2]) + selectedWord(1).assertHasClickAction() + + tap("SelectedWord-1") + assertSelectedWord(1, "") + selectedWord(1).assertHasNoClickAction() + + waitForClickDebounce() + completeFrom(0) + composeTestRule.onNodeWithTag("ContinueConfirmMnemonic").assertIsEnabled() + } + + @Test + fun correctWord_isNotClickableAndStaysSelected() { + setScreen() + + tapChip(WORDS[0]) + assertSelectedWord(1, WORDS[0]) + selectedWord(1).assertHasNoClickAction() + + waitForClickDebounce() + tapChip(WORDS[0]) + assertSelectedWord(1, WORDS[0]) + composeTestRule.onNodeWithTag("ContinueConfirmMnemonic").assertIsNotEnabled() + } + + @Test + fun anotherChipAfterWrongWord_doesNothing_andWrongChipClearsIt() { + setScreen() + + tapChip(WORDS[2]) + tapChip(WORDS[0]) + assertSelectedWord(1, WORDS[2]) + assertSelectedWord(2, "") + + waitForClickDebounce() + tapChip(WORDS[2]) + assertSelectedWord(1, "") + selectedWord(1).assertHasNoClickAction() + } + + @Test + fun chipOrderAndSelection_surviveRecreation() { + val tester = StateRestorationTester(composeTestRule) + setScreen(tester) + + tapChip(WORDS[0]) + tapChip(WORDS[2]) + val orderBefore = chipOrder() + assertNotEquals(WORDS, orderBefore, "chips should be shuffled") + + tester.emulateSavedInstanceStateRestore() + composeTestRule.waitForIdle() + + assertEquals(orderBefore, chipOrder()) + assertSelectedWord(1, WORDS[0]) + assertSelectedWord(2, WORDS[2]) + selectedWord(2).assertHasClickAction() + + tapChip(WORDS[2]) + assertSelectedWord(2, "") + + waitForClickDebounce() + completeFrom(1) + composeTestRule.onNodeWithTag("ContinueConfirmMnemonic").assertIsEnabled() + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt index c4bd020b11..82020fb20b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -37,11 +38,11 @@ import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.effects.BlockScreenshots +import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -@Suppress("CyclomaticComplexMethod") @Composable fun ConfirmMnemonicScreen( uiState: BackupContract.UiState, @@ -53,71 +54,88 @@ fun ConfirmMnemonicScreen( val originalSeed = remember(uiState.bip39Mnemonic) { uiState.bip39Mnemonic.split(" ").filter { it.isNotBlank() }.toImmutableList() } - val shuffledWords = remember(originalSeed) { - originalSeed.shuffled().toImmutableList() + val shuffledOrder = rememberSaveable(originalSeed) { originalSeed.indices.shuffled() } + val shuffledWords = remember(originalSeed, shuffledOrder) { + shuffledOrder.map { originalSeed[it] }.toImmutableList() } - var selectedWords by rememberSaveable { - mutableStateOf(arrayOfNulls(originalSeed.size)) - } - var pressedStates by rememberSaveable { - mutableStateOf(BooleanArray(shuffledWords.size) { false }) + var selectedIndices by rememberSaveable(originalSeed) { + mutableStateOf(listOf()) } - // Calculate if all words are correct - val isComplete = selectedWords.all { it != null } && - selectedWords.zip(originalSeed).all { (selected, original) -> selected == original } + val isComplete = isMnemonicSelectionComplete(selectedIndices, shuffledWords, originalSeed) ConfirmMnemonicContent( originalSeed = originalSeed, shuffledWords = shuffledWords, - selectedWords = selectedWords, - pressedStates = pressedStates, + selectedIndices = selectedIndices.toImmutableList(), isComplete = isComplete, - onWordPress = { word, shuffledIndex -> - val firstNullIndex = selectedWords.indexOfFirst { it == null } - if (firstNullIndex == -1) return@ConfirmMnemonicContent - - val lastIndex = firstNullIndex - 1 - val nextIndex = if (lastIndex == -1) 0 else lastIndex + 1 - - // If the word is correct and pressed, do nothing - if (pressedStates[shuffledIndex] && nextIndex > 0 && originalSeed[lastIndex] == selectedWords[lastIndex]) { - return@ConfirmMnemonicContent - } - - // If previous word is incorrect, allow unchecking - if (lastIndex >= 0 && selectedWords[lastIndex] != originalSeed[lastIndex]) { - // Uncheck if we tap on it - if (pressedStates[shuffledIndex] && word == selectedWords[lastIndex]) { - pressedStates = pressedStates.copyOf().apply { this[shuffledIndex] = false } - selectedWords = selectedWords.copyOf().apply { this[lastIndex] = null } - } - return@ConfirmMnemonicContent - } - - // Mark word as pressed and add it to the seed - if (nextIndex < originalSeed.size) { - pressedStates = pressedStates.copyOf().apply { this[shuffledIndex] = true } - selectedWords = selectedWords.copyOf().apply { this[nextIndex] = word } - } + onWordPress = { shuffledIndex -> + selectedIndices = reduceMnemonicSelection(selectedIndices, shuffledIndex, shuffledWords, originalSeed) + }, + onSelectedWordPress = { + val lastIndex = selectedIndices.lastOrNull() ?: return@ConfirmMnemonicContent + selectedIndices = reduceMnemonicSelection(selectedIndices, lastIndex, shuffledWords, originalSeed) }, onContinue = onContinue, onBack = onBack, ) } +/** + * Applies a tap on the shuffled word chip at [tappedShuffledIndex] to the [stack] of selected chip indices. + * An incorrect last word blocks further selection and is removed only by tapping its own chip. + */ +internal fun reduceMnemonicSelection( + stack: List, + tappedShuffledIndex: Int, + shuffled: List, + original: List, +): List { + if (tappedShuffledIndex !in shuffled.indices) return stack + if (!isSelectionPositionCorrect(stack, stack.lastIndex, shuffled, original)) { + return if (stack.last() == tappedShuffledIndex) stack.dropLast(1) else stack + } + if (tappedShuffledIndex in stack) return stack + if (stack.size >= original.size) return stack + return stack + tappedShuffledIndex +} + +internal fun isMnemonicSelectionComplete( + stack: List, + shuffled: List, + original: List, +): Boolean = original.isNotEmpty() && + stack.size == original.size && + stack.indices.all { isSelectionPositionCorrect(stack, it, shuffled, original) } + +private fun isSelectionPositionCorrect( + stack: List, + position: Int, + shuffled: List, + original: List, +): Boolean { + if (position < 0) return true + val word = shuffled.getOrNull(stack[position]) ?: return false + return word == original.getOrNull(position) +} + @Composable private fun ConfirmMnemonicContent( originalSeed: ImmutableList, shuffledWords: ImmutableList, - selectedWords: Array, - pressedStates: BooleanArray, + selectedIndices: ImmutableList, isComplete: Boolean, - onWordPress: (String, Int) -> Unit, + onWordPress: (Int) -> Unit, + onSelectedWordPress: () -> Unit, onContinue: () -> Unit, onBack: () -> Unit, ) { + val selectedWords = remember(originalSeed, shuffledWords, selectedIndices) { + List(originalSeed.size) { position -> + selectedIndices.getOrNull(position)?.let { shuffledWords.getOrNull(it) } + }.toImmutableList() + } val scrollState = rememberScrollState() val scope = rememberCoroutineScope() @@ -163,14 +181,14 @@ private fun ConfirmMnemonicContent( .testTag("backup_shuffled_words_grid") ) { shuffledWords.forEachIndexed { index, word -> - val isSelected = pressedStates.getOrElse(index, defaultValue = { false }) + val isSelected = index in selectedIndices PrimaryButton( text = word, color = if (isSelected) Colors.White32 else Colors.White16, enableGradient = !isSelected, fullWidth = false, size = ButtonSize.Small, - onClick = { onWordPress(word, index) }, + onClick = { onWordPress(index) }, modifier = Modifier.testTag("Word-$word") ) } @@ -192,6 +210,7 @@ private fun ConfirmMnemonicContent( number = index + 1, word = word ?: "", isCorrect = word == originalSeed.getOrNull(index), + onClick = onSelectedWordPress, ) } } @@ -205,6 +224,7 @@ private fun ConfirmMnemonicContent( number = actualIndex + 1, word = word ?: "", isCorrect = word == originalSeed.getOrNull(actualIndex), + onClick = onSelectedWordPress, ) } } @@ -230,8 +250,14 @@ private fun SelectedWordItem( number: Int, word: String, isCorrect: Boolean, + onClick: () -> Unit, ) { - Row { + val isIncorrect = word.isNotEmpty() && !isCorrect + Row( + modifier = Modifier + .clickableAlpha(enabled = isIncorrect, onClick = onClick) + .testTag("SelectedWord-$number") + ) { BodyMSB(text = "$number.", color = Colors.White64) Spacer(modifier = Modifier.width(4.dp)) BodyMSB( @@ -249,10 +275,10 @@ private fun Preview() { ConfirmMnemonicContent( originalSeed = testWords, shuffledWords = testWords.shuffled().toImmutableList(), - selectedWords = arrayOfNulls(testWords.size), - pressedStates = BooleanArray(testWords.size) { false }, + selectedIndices = persistentListOf(), isComplete = false, - onWordPress = { _, _ -> }, + onWordPress = {}, + onSelectedWordPress = {}, onContinue = {}, onBack = {}, ) @@ -267,11 +293,11 @@ private fun Preview2() { AppThemeSurface { ConfirmMnemonicContent( originalSeed = testWords, - shuffledWords = testWords.shuffled().toImmutableList(), - selectedWords = testWords.take(half).toTypedArray() + arrayOfNulls(half), - pressedStates = BooleanArray(testWords.size) { it < half }, + shuffledWords = testWords, + selectedIndices = List(half) { it }.toImmutableList(), isComplete = false, - onWordPress = { _, _ -> }, + onWordPress = {}, + onSelectedWordPress = {}, onContinue = {}, onBack = {}, ) @@ -286,11 +312,11 @@ private fun Preview24Words() { AppThemeSurface { ConfirmMnemonicContent( originalSeed = testWords, - shuffledWords = testWords.shuffled().toImmutableList(), - selectedWords = testWords.take(half).toTypedArray() + arrayOfNulls(half), - pressedStates = BooleanArray(testWords.size) { it < half }, + shuffledWords = testWords, + selectedIndices = List(half) { it }.toImmutableList(), isComplete = false, - onWordPress = { _, _ -> }, + onWordPress = {}, + onSelectedWordPress = {}, onContinue = {}, onBack = {}, ) diff --git a/app/src/test/java/to/bitkit/ui/settings/backups/ConfirmMnemonicSelectionTest.kt b/app/src/test/java/to/bitkit/ui/settings/backups/ConfirmMnemonicSelectionTest.kt new file mode 100644 index 0000000000..06a690d208 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/settings/backups/ConfirmMnemonicSelectionTest.kt @@ -0,0 +1,93 @@ +package to.bitkit.ui.settings.backups + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConfirmMnemonicSelectionTest { + private val original = listOf("alpha", "bravo", "charlie", "delta") + private val shuffled = listOf("charlie", "alpha", "delta", "bravo") + + private fun tap(stack: List, index: Int) = reduceMnemonicSelection(stack, index, shuffled, original) + + @Test + fun `pushes correct words in order`() { + val stack = listOf(1, 3, 0, 2).fold(emptyList()) { acc, index -> tap(acc, index) } + + assertEquals(listOf(1, 3, 0, 2), stack) + assertTrue(isMnemonicSelectionComplete(stack, shuffled, original)) + } + + @Test + fun `wrong word is removed by tapping its chip again`() { + val stack = tap(emptyList(), 0) + + assertEquals(listOf(0), stack) + assertEquals(emptyList(), tap(stack, 0)) + } + + @Test + fun `wrong word is removed by tapping the red word`() { + val stack = tap(listOf(1), 2) + + assertEquals(listOf(1), tap(stack, stack.last())) + } + + @Test + fun `tapping another chip after a wrong word does nothing`() { + val stack = tap(emptyList(), 0) + + assertEquals(listOf(0), tap(stack, 1)) + assertEquals(listOf(0), tap(stack, 3)) + } + + @Test + fun `wrong last word can be removed when all slots are filled`() { + val shuffled = listOf("charlie", "alpha", "echo", "bravo", "delta") + val stack = listOf(1, 3, 0, 2) + + assertFalse(isMnemonicSelectionComplete(stack, shuffled, original)) + assertEquals(stack, reduceMnemonicSelection(stack, 4, shuffled, original)) + assertEquals(listOf(1, 3, 0), reduceMnemonicSelection(stack, 2, shuffled, original)) + } + + @Test + fun `correct word cannot be removed`() { + val stack = listOf(1, 3) + + assertEquals(stack, tap(stack, 3)) + assertEquals(stack, tap(stack, 1)) + } + + @Test + fun `full correct selection ignores further taps`() { + val stack = listOf(1, 3, 0, 2) + + assertEquals(stack, tap(stack, 2)) + assertEquals(stack, tap(stack, 0)) + } + + @Test + fun `duplicate words are tracked per chip`() { + val original = listOf("alpha", "bravo", "alpha") + val shuffled = listOf("alpha", "bravo", "alpha") + + var stack = reduceMnemonicSelection(emptyList(), 2, shuffled, original) + assertEquals(listOf(2), stack) + + stack = reduceMnemonicSelection(stack, 2, shuffled, original) + assertEquals(listOf(2), stack) + + stack = reduceMnemonicSelection(stack, 1, shuffled, original) + stack = reduceMnemonicSelection(stack, 0, shuffled, original) + assertEquals(listOf(2, 1, 0), stack) + assertTrue(isMnemonicSelectionComplete(stack, shuffled, original)) + } + + @Test + fun `out of range tap does nothing`() { + assertEquals(emptyList(), tap(emptyList(), shuffled.size)) + assertEquals(emptyList(), tap(emptyList(), -1)) + } +} diff --git a/changelog.d/next/634.fixed.md b/changelog.d/next/634.fixed.md new file mode 100644 index 0000000000..0aa5841d61 --- /dev/null +++ b/changelog.d/next/634.fixed.md @@ -0,0 +1 @@ +A wrong word in the recovery phrase confirmation can now be cleared by tapping the red word or its button, including the last word. diff --git a/journeys/README.md b/journeys/README.md index f1d8ab2005..b58f7c3949 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -117,6 +117,7 @@ fixtures, push notifications) live in each suite's README. | [activity](activity) | 1 | Date range sheet under rapid month taps; needs no backend, no README | | [amount-limits](amount-limits) | 5 | Number pad caps on all four amount screens, plus preset/unit-switch delete | | [app-update](app-update) | 1 | Critical update blocks onboarding; cannot be run from a stock build — needs a non-debug build and a local change to reach a critical release, see the journey's setup; no README | +| [backup](backup) | 1 | Clearing a wrong word on Confirm Recovery Phrase; throwaway wallet only, no README | | [backup-restore](backup-restore) | 1 | VSS restore keeps tags and closed channels; wipes the wallet | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [coin-selection](coin-selection) | 1 | Manual coin selection screen; needs 3+ on-chain UTXOs; no README | @@ -176,6 +177,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `tags/activity-tag-length-cap.xml` | not ported — iOS has no 20-character cap on tag input | | `transfer/transfer-to-savings-returns-home.xml` | not ported — iOS already resets navigation to home on the same OK, so the journey has no iOS counterpart yet | | `lnurl/lnurl-pay-comment-note.xml` | not ported — bitkit-ios has not been checked for keeping the LNURL-pay comment on the activity | +| `backup/confirm-mnemonic-clear-wrong-word.xml` | not ported — `BackupConfirmMnemonic.swift` clears only the last word by its chip, with no red-word tap | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | ### Running one on iOS @@ -205,6 +207,7 @@ and Settings (`Tab-general`, `Tab-security`, `Tab-advanced`, `NavigationBack`, ` | External amount available | — | `ExternalAmountAvailable` | | Background payments setting row | `BackgroundPaymentSettings` | `NotificationsSettings` | | Send over-max toast | — *(no tag; assert it from a screenshot)* | `SendAmountExceededToast` | +| Confirm mnemonic selected slot | `SelectedWord-` | — *(no identifier on `ConfirmWordView` rows)* | | Widgets intro screen container | — | `WidgetsOnboarding` | | Home suggestion cards | `Suggestion-` | — *(cards expose no identifier)* | | Receive QR copy button | `ReceiveCopyQR` | `ReceiveCopyQR` *(absent from `snapshot-ui` targets; see below)* | diff --git a/journeys/backup/confirm-mnemonic-clear-wrong-word.xml b/journeys/backup/confirm-mnemonic-clear-wrong-word.xml new file mode 100644 index 0000000000..8e2b77c6f3 --- /dev/null +++ b/journeys/backup/confirm-mnemonic-clear-wrong-word.xml @@ -0,0 +1,45 @@ + + + Proves that a wrong word on Confirm Recovery Phrase can be cleared by tapping either the red word + or its own chip, that nothing else can be selected while a wrong word is shown, and that correct + words stay locked in. Follows the Figma note on 'Backup Recovery Phrase Incorrect'. + Precondition: onboarded dev wallet with PIN disabled (or a PIN you know), no BIP39 passphrase. + Stopping before Continue leaves the backupVerified setting unchanged. + The route reveals the recovery phrase. Run it only on a throwaway wallet, and never copy the words + into logs, reports or files; refer to them by position. "Word N" below means the Nth word shown on + the Your Recovery Phrase screen, and "chip for word N" means the shuffled button tagged + "Word-(that word)". Every slot carries the testTag "SelectedWord-N" whether filled or not, but the + row is only clickable while it shows a wrong word, so assert on the word's colour and on whether + the row reacts to a tap, not on the tag being present. + + + Tap the menu icon (testTag "HeaderMenu") + Tap "Settings" (testTag "DrawerSettings") + Tap the Security tab (testTag "Tab-security") + Tap "Back up your wallet" (testTag "BackupWallet") + Tap "Tap To Reveal" (testTag "TapToReveal") and note the 12 words by position only + Tap "Continue" (testTag "ContinueShowMnemonic") + Verify that "Confirm Recovery Phrase" (testTag "backup_confirm_mnemonic_screen") is visible with 12 word chips and empty slots 1 to 12 + Tap the chip for word 1 + Verify that slot 1 (testTag "SelectedWord-1") shows word 1 in green + Tap the chip for word 3 + Verify that slot 2 (testTag "SelectedWord-2") shows word 3 in red + Tap the chip for word 2 + Verify that slot 2 still shows word 3 in red and the chip for word 2 is not selected + Tap the red word in slot 2 (testTag "SelectedWord-2") + Verify that slot 2 is empty and the chip for word 3 is back in its unselected style + Tap the chip for word 3 + Verify that slot 2 shows word 3 in red + Tap the chip for word 3 again + Verify that slot 2 is empty and the chip for word 3 is back in its unselected style + Tap the green word in slot 1 (testTag "SelectedWord-1") + Verify that slot 1 still shows word 1 in green + Tap the chip for word 1 + Verify that slot 1 still shows word 1 in green and slot 2 is still empty + Tap the chips for words 2 to 11 in order + Verify that slots 1 to 11 show green words and "Continue" (testTag "ContinueConfirmMnemonic") is disabled + Tap the chip for word 12 + Verify that all 12 slots are green and "Continue" (testTag "ContinueConfirmMnemonic") is enabled + Press the system back button until the home screen (testTag "HomeScrollView") is visible, without tapping Continue + +