From 71550824e4a662396f397f560b54ca7fe30c2423 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 20:51:57 -0300 Subject: [PATCH 01/10] fix: spread pasted seed fragment Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/onboarding/RestoreWalletScreen.kt | 7 +- .../viewmodels/RestoreWalletViewModel.kt | 83 ++++++++--- .../viewmodels/RestoreWalletViewModelTest.kt | 137 ++++++++++++++++++ changelog.d/next/896.fixed.md | 1 + 4 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 changelog.d/next/896.fixed.md diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index f58f184397..87bb2615db 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -76,6 +76,9 @@ import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.RestoreWalletUiState import to.bitkit.viewmodels.RestoreWalletViewModel +/** Input containing whitespace is a pasted phrase, which the view model spreads across the word fields. */ +private val WHITESPACE = Regex("\\s") + @Composable fun RestoreWalletScreen( onBackClick: () -> Unit, @@ -379,7 +382,7 @@ fun MnemonicInputField( OutlinedTextField( value = textFieldValue, onValueChange = { - textFieldValue = it + if (!it.text.contains(WHITESPACE)) textFieldValue = it onValueChange(it.text) }, textStyle = AppTextStyles.BodySSB, @@ -405,7 +408,7 @@ fun MnemonicInputField( .onPreviewKeyEvent { keyEvent -> if (keyEvent.key == Key.Backspace && keyEvent.type == KeyEventType.KeyDown && - value.isEmpty() + textFieldValue.text.isEmpty() ) { onBackspaceInEmpty() true diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index 4e4a8815eb..d9de0f9af6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -48,7 +48,7 @@ class RestoreWalletViewModel @Inject constructor( fun onChangeWord(index: Int, value: String) { if (value.contains(Regex("\\s"))) { - handlePastedWords(value) + handlePastedWords(index, value) } else { updateWordValidity(index, value) updateSuggestions(value, _uiState.value.focusedIndex) @@ -103,35 +103,72 @@ class RestoreWalletViewModel @Inject constructor( fun onScrollComplete() = _uiState.update { it.copy(scrollToFieldIndex = null) } - private fun handlePastedWords(pastedText: String) = viewModelScope.launch { + private fun handlePastedWords(index: Int, pastedText: String) = viewModelScope.launch { val separators = Regex("\\s+") // any whitespace chars to account for different sources like password managers val pastedWords = pastedText .split(separators) .filter { it.isNotBlank() } - if (pastedWords.size == WORDS_MIN || pastedWords.size == WORDS_MAX) { - val invalidIndices = pastedWords.withIndex() - .filter { !bip39Service.isValidWord(it.value) } - .map { it.index } - .toSet() - - val newWords = _uiState.value.words.toMutableList().apply { - pastedWords.forEachIndexed { index, word -> this[index] = word } - for (index in pastedWords.size until WORDS_MAX) { - this[index] = "" - } + when (pastedWords.size) { + 0 -> return@launch + WORDS_MIN, WORDS_MAX -> replaceAllWords(pastedWords) + else -> spreadWords(index, pastedWords) + } + recomputeValidationState() + } + + private suspend fun replaceAllWords(pastedWords: List) { + val invalidIndices = pastedWords.withIndex() + .filter { !bip39Service.isValidWord(it.value) } + .map { it.index } + .toSet() + + val newWords = _uiState.value.words.toMutableList().apply { + pastedWords.forEachIndexed { index, word -> this[index] = word } + for (index in pastedWords.size until WORDS_MAX) { + this[index] = "" } + } - _uiState.update { - it.copy( - words = newWords.toImmutableList(), - invalidWordIndices = invalidIndices.toImmutableSet(), - is24Words = pastedWords.size == WORDS_MAX, - shouldDismissKeyboard = invalidIndices.isEmpty(), - focusedIndex = null, - suggestions = persistentListOf(), - ) + _uiState.update { + it.copy( + words = newWords.toImmutableList(), + invalidWordIndices = invalidIndices.toImmutableSet(), + is24Words = pastedWords.size == WORDS_MAX, + shouldDismissKeyboard = invalidIndices.isEmpty(), + focusedIndex = null, + suggestions = persistentListOf(), + ) + } + } + + private suspend fun spreadWords(startIndex: Int, pastedWords: List) { + val writtenWords = pastedWords.take(WORDS_MAX - startIndex) + val writtenValidity = writtenWords.map { bip39Service.isValidWord(it) } + val lastWrittenIndex = startIndex + writtenWords.lastIndex + + _uiState.update { state -> + val newWords = state.words.toMutableList() + val newInvalidIndices = state.invalidWordIndices.toMutableSet() + writtenWords.forEachIndexed { offset, word -> + val index = startIndex + offset + newWords[index] = word + if (writtenValidity[offset]) newInvalidIndices.remove(index) else newInvalidIndices.add(index) } - recomputeValidationState() + + val is24Words = state.is24Words || lastWrittenIndex >= WORDS_MIN + val wordCount = if (is24Words) WORDS_MAX else WORDS_MIN + val nextEmptyIndex = (lastWrittenIndex + 1 until wordCount).firstOrNull { newWords[it].isEmpty() } + ?: (0 until wordCount).firstOrNull { newWords[it].isEmpty() } + + state.copy( + words = newWords.toImmutableList(), + invalidWordIndices = newInvalidIndices.toImmutableSet(), + is24Words = is24Words, + shouldDismissKeyboard = nextEmptyIndex == null && newInvalidIndices.isEmpty(), + focusedIndex = nextEmptyIndex, + scrollToFieldIndex = nextEmptyIndex ?: lastWrittenIndex, + suggestions = persistentListOf(), + ) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt index e8ee09a42f..b60215e4be 100644 --- a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -268,6 +268,143 @@ class RestoreWalletViewModelTest : BaseUnitTest() { assertFalse(state.shouldDismissKeyboard) } + @Test + fun `handlePastedWords should keep full replace when pasting 12 words into later field`() { + val words = List(12) { "w${it + 1}" }.joinToString(" ") + + viewModel.onChangeWord(5, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w12", state.words[11]) + assertEquals("", state.words[12]) + assertFalse(state.is24Words) + } + + @Test + fun `handlePastedWords should spread fragment from first field`() { + viewModel.onChangeWord(0, "abandon ability able") + + val state = viewModel.uiState.value + assertEquals(listOf("abandon", "ability", "able"), state.words.subList(0, 3)) + assertTrue(state.words.drop(3).all { it.isEmpty() }) + assertFalse(state.is24Words) + assertEquals(3, state.focusedIndex) + assertEquals(3, state.scrollToFieldIndex) + assertTrue(state.suggestions.isEmpty()) + assertFalse(state.shouldDismissKeyboard) + } + + @Test + fun `handlePastedWords should spread fragment from middle field`() { + viewModel.onChangeWord(0, "about") + viewModel.onChangeWord(8, "access") + + viewModel.onChangeWord(5, "abandon ability able") + + val state = viewModel.uiState.value + assertEquals("about", state.words[0]) + assertEquals("", state.words[4]) + assertEquals(listOf("abandon", "ability", "able"), state.words.subList(5, 8)) + assertEquals("access", state.words[8]) + assertEquals(9, state.focusedIndex) + } + + @Test + fun `handlePastedWords should switch to 24 words when fragment passes field 12`() { + val words = List(15) { "w${it + 1}" }.joinToString(" ") + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertTrue(state.is24Words) + assertEquals("w1", state.words[0]) + assertEquals("w15", state.words[14]) + assertEquals("", state.words[15]) + assertEquals(15, state.focusedIndex) + } + + @Test + fun `handlePastedWords should drop words beyond field 24`() { + val words = List(5) { "w${it + 1}" }.joinToString(" ") + + viewModel.onChangeWord(21, words) + + val state = viewModel.uiState.value + assertTrue(state.is24Words) + assertEquals(listOf("w1", "w2", "w3"), state.words.subList(21, 24)) + assertEquals(24, state.words.size) + } + + @Test + fun `handlePastedWords should mark invalid word in fragment`() = runBlocking { + whenever(bip39Service.isValidWord("zzzz")).thenReturn(false) + + viewModel.onChangeWord(2, "abandon zzzz able") + + val state = viewModel.uiState.value + assertEquals("zzzz", state.words[3]) + assertEquals(setOf(3), state.invalidWordIndices) + } + + @Test + fun `handlePastedWords should clear invalid flag of overwritten fields`() = runBlocking { + whenever(bip39Service.isValidWord("zzzz")).thenReturn(false) + viewModel.onChangeWord(1, "zzzz") + assertTrue(viewModel.uiState.value.invalidWordIndices.contains(1)) + + viewModel.onChangeWord(0, "abandon ability") + + val state = viewModel.uiState.value + assertEquals("ability", state.words[1]) + assertTrue(state.invalidWordIndices.isEmpty()) + } + + @Test + fun `handlePastedWords should write word with trailing space to its field`() { + viewModel.onChangeWord(4, "abandon ") + + val state = viewModel.uiState.value + assertEquals("abandon", state.words[4]) + assertTrue(state.words.withIndex().filter { it.index != 4 }.all { it.value.isEmpty() }) + assertFalse(state.is24Words) + } + + @Test + fun `handlePastedWords should ignore whitespace only input`() { + viewModel.onChangeWord(0, "abandon") + + viewModel.onChangeWord(0, " ") + + val state = viewModel.uiState.value + assertEquals("abandon", state.words[0]) + } + + @Test + fun `handlePastedWords should focus first empty field when fragment fills the tail`() { + for (i in 3 until 9) { + viewModel.onChangeWord(i, "word$i") + } + + viewModel.onChangeWord(9, "abandon ability able") + + assertEquals(0, viewModel.uiState.value.focusedIndex) + } + + @Test + fun `handlePastedWords should clear focus and dismiss keyboard when fragment completes phrase`() { + for (i in 0 until 9) { + viewModel.onChangeWord(i, "word$i") + } + + viewModel.onChangeWord(9, "abandon ability able") + + val state = viewModel.uiState.value + assertNull(state.focusedIndex) + assertTrue(state.shouldDismissKeyboard) + assertTrue(state.areButtonsEnabled) + } + // endregion // region Focus Management diff --git a/changelog.d/next/896.fixed.md b/changelog.d/next/896.fixed.md new file mode 100644 index 0000000000..457336b938 --- /dev/null +++ b/changelog.d/next/896.fixed.md @@ -0,0 +1 @@ +Pasting part of a recovery phrase on the restore screen now fills the following word fields instead of being ignored. From 2d9db175bf252d41d3a7beb07f6b988bd8630b6d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 20:59:36 -0300 Subject: [PATCH 02/10] fix: spread 12 pasted words into later seed fields Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/onboarding/MnemonicInputFieldTest.kt | 121 ++++++++++++++++++ .../viewmodels/RestoreWalletViewModel.kt | 8 +- .../viewmodels/RestoreWalletViewModelTest.kt | 48 ++++++- 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt new file mode 100644 index 0000000000..3d45bed433 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt @@ -0,0 +1,121 @@ +package to.bitkit.ui.onboarding + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface +import kotlin.test.assertEquals + +/** + * Regression pin for #896: a pasted fragment must not stay in the field it was pasted into, and + * Backspace must edit a field that shows text even when the parent has not stored that text. + */ +@OptIn(ExperimentalTestApi::class) +@ComposeUi +class MnemonicInputFieldTest { + + private companion object { + const val FIELD_TAG = "Word-0" + } + + @get:Rule + val composeTestRule = createComposeRule() + + private val changes = mutableListOf() + private var backspaceInEmptyCount = 0 + + @Before + fun setup() { + changes.clear() + backspaceInEmptyCount = 0 + } + + private fun setContent(onValueChange: (String) -> String? = { null }) { + composeTestRule.setContent { + var value by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + AppThemeSurface { + MnemonicInputField( + label = "1.", + value = value, + onValueChange = { + changes += it + onValueChange(it)?.let { newValue -> value = newValue } + }, + onFocusChange = {}, + onPositionChange = {}, + onBackspaceInEmpty = { backspaceInEmptyCount++ }, + focusRequester = focusRequester, + index = 0, + ) + } + } + composeTestRule.waitForIdle() + } + + private fun fieldText() = composeTestRule.onNodeWithTag(FIELD_TAG) + .fetchSemanticsNode() + .config[SemanticsProperties.EditableText] + .text + + @Test + fun whenTextWithWhitespaceEntered_shouldNotKeepIt() { + setContent() + + composeTestRule.onNodeWithTag(FIELD_TAG).performTextInput("abandon ability able") + composeTestRule.waitForIdle() + + assertEquals(listOf("abandon ability able"), changes) + assertEquals("", fieldText()) + } + + @Test + fun whenFragmentPastedAndParentStoresFirstWord_shouldShowOnlyThatWord() { + setContent(onValueChange = { it.trim().split(Regex("\\s+")).first() }) + + composeTestRule.onNodeWithTag(FIELD_TAG).performTextInput("abandon ability able") + composeTestRule.waitForIdle() + + assertEquals("abandon", fieldText()) + } + + @Test + fun whenBackspacePressedInFieldWithText_shouldDeleteAndNotCallBackspaceInEmpty() { + setContent() + composeTestRule.onNodeWithTag(FIELD_TAG).performTextInput("abandon") + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(FIELD_TAG).performKeyInput { pressKey(Key.Backspace) } + composeTestRule.waitForIdle() + + assertEquals(0, backspaceInEmptyCount) + assertEquals("abando", fieldText()) + } + + @Test + fun whenBackspacePressedInEmptyField_shouldCallBackspaceInEmpty() { + setContent() + composeTestRule.onNodeWithTag(FIELD_TAG).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(FIELD_TAG).performKeyInput { pressKey(Key.Backspace) } + composeTestRule.waitForIdle() + + assertEquals(1, backspaceInEmptyCount) + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index d9de0f9af6..2ffd4df7b6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -108,9 +108,12 @@ class RestoreWalletViewModel @Inject constructor( val pastedWords = pastedText .split(separators) .filter { it.isNotBlank() } + val state = _uiState.value + val isFullPhraseTarget = index == 0 || !state.is24Words && state.hasNoWordsExcept(index) when (pastedWords.size) { 0 -> return@launch - WORDS_MIN, WORDS_MAX -> replaceAllWords(pastedWords) + WORDS_MAX -> replaceAllWords(pastedWords) + WORDS_MIN if isFullPhraseTarget -> replaceAllWords(pastedWords) else -> spreadWords(index, pastedWords) } recomputeValidationState() @@ -209,6 +212,9 @@ class RestoreWalletViewModel @Inject constructor( _uiState.update { it.copy(suggestions = filtered.toImmutableList()) } } + private fun RestoreWalletUiState.hasNoWordsExcept(index: Int) = + words.withIndex().none { it.index != index && it.value.isNotEmpty() } + private suspend fun RestoreWalletUiState.areButtonsEnabled(): Boolean { val activeWords = words.subList(0, wordCount) return activeWords.none { it.isBlank() } && diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt index b60215e4be..8c786693e2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -269,7 +269,7 @@ class RestoreWalletViewModelTest : BaseUnitTest() { } @Test - fun `handlePastedWords should keep full replace when pasting 12 words into later field`() { + fun `handlePastedWords should keep full replace when pasting 12 words into later field of empty form`() { val words = List(12) { "w${it + 1}" }.joinToString(" ") viewModel.onChangeWord(5, words) @@ -281,6 +281,52 @@ class RestoreWalletViewModelTest : BaseUnitTest() { assertFalse(state.is24Words) } + @Test + fun `handlePastedWords should spread 12 words into field 13 when earlier fields hold words`() { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "a${i + 1}") + } + val secondHalf = List(12) { "b${it + 13}" }.joinToString(" ") + + viewModel.onChangeWord(12, secondHalf) + + val state = viewModel.uiState.value + assertEquals(List(12) { "a${it + 1}" }, state.words.subList(0, 12)) + assertEquals(List(12) { "b${it + 13}" }, state.words.subList(12, 24)) + assertTrue(state.is24Words) + assertNull(state.focusedIndex) + assertTrue(state.areButtonsEnabled) + } + + @Test + fun `handlePastedWords should spread 12 words into later field on empty 24 word layout`() { + viewModel.onChangeWord(0, List(24) { "a${it + 1}" }.joinToString(" ")) + for (i in 0 until 24) { + viewModel.onChangeWord(i, "") + } + val secondHalf = List(12) { "b${it + 13}" }.joinToString(" ") + + viewModel.onChangeWord(12, secondHalf) + + val state = viewModel.uiState.value + assertTrue(state.words.subList(0, 12).all { it.isEmpty() }) + assertEquals(List(12) { "b${it + 13}" }, state.words.subList(12, 24)) + assertTrue(state.is24Words) + assertEquals(0, state.focusedIndex) + } + + @Test + fun `handlePastedWords should full replace 12 words pasted into first field on 24 word layout`() { + viewModel.onChangeWord(0, List(24) { "a${it + 1}" }.joinToString(" ")) + + viewModel.onChangeWord(0, List(12) { "b${it + 1}" }.joinToString(" ")) + + val state = viewModel.uiState.value + assertEquals(List(12) { "b${it + 1}" }, state.words.subList(0, 12)) + assertTrue(state.words.subList(12, 24).all { it.isEmpty() }) + assertFalse(state.is24Words) + } + @Test fun `handlePastedWords should spread fragment from first field`() { viewModel.onChangeWord(0, "abandon ability able") From 49d8213d9059cf91d76224d164004d0b4b5300a4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 21:32:00 -0300 Subject: [PATCH 03/10] fix: keep cursor at end of synced seed word Co-Authored-By: Claude Opus 5 (1M context) --- .../bitkit/ui/onboarding/MnemonicInputFieldTest.kt | 13 +++++++++++++ .../to/bitkit/ui/onboarding/RestoreWalletScreen.kt | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt index 3d45bed433..584e6accc3 100644 --- a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt @@ -94,6 +94,19 @@ class MnemonicInputFieldTest { assertEquals("abandon", fieldText()) } + @Test + fun whenFragmentPastedAndParentStoresFirstWord_shouldDeleteFromEndOnBackspace() { + setContent(onValueChange = { it.trim().split(Regex("\\s+")).first() }) + composeTestRule.onNodeWithTag(FIELD_TAG).performTextInput("abandon ability able") + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(FIELD_TAG).performKeyInput { pressKey(Key.Backspace) } + composeTestRule.waitForIdle() + + assertEquals(0, backspaceInEmptyCount) + assertEquals("abando", fieldText()) + } + @Test fun whenBackspacePressedInFieldWithText_shouldDeleteAndNotCallBackspaceInEmpty() { setContent() diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index 87bb2615db..f76f7ce5d0 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -371,11 +372,10 @@ fun MnemonicInputField( ) { var textFieldValue by remember { mutableStateOf(TextFieldValue()) } - // Sync text from parent while preserving selection + // Sync text from parent with the cursor at the end, so Backspace edits a pasted or suggested word LaunchedEffect(value) { if (textFieldValue.text != value) { - val selection = textFieldValue.selection - textFieldValue = TextFieldValue(value, selection) + textFieldValue = TextFieldValue(value, TextRange(value.length)) } } From 4af0ab9c6e8ef06b1c182a88ec6b94d953aa38f9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 17:39:00 -0300 Subject: [PATCH 04/10] fix: ignore typed spaces and serialize seed word edits Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/onboarding/MnemonicInputFieldTest.kt | 14 +++ .../ui/onboarding/RestoreWalletScreen.kt | 21 ++++- .../viewmodels/RestoreWalletViewModel.kt | 87 +++++++++++-------- .../ui/onboarding/MnemonicInputPasteTest.kt | 42 +++++++++ .../viewmodels/RestoreWalletViewModelTest.kt | 23 +++++ 5 files changed, 146 insertions(+), 41 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt index 584e6accc3..77da7be4ae 100644 --- a/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt @@ -84,6 +84,20 @@ class MnemonicInputFieldTest { assertEquals("", fieldText()) } + @Test + fun whenSpaceTypedAfterWord_shouldNotForwardItAsPaste() { + setContent(onValueChange = { it }) + composeTestRule.onNodeWithTag(FIELD_TAG).performTextInput("abandon") + composeTestRule.waitForIdle() + changes.clear() + + composeTestRule.onNodeWithTag(FIELD_TAG).performTextInput(" ") + composeTestRule.waitForIdle() + + assertEquals(emptyList(), changes) + assertEquals("abandon", fieldText()) + } + @Test fun whenFragmentPastedAndParentStoresFirstWord_shouldShowOnlyThatWord() { setContent(onValueChange = { it.trim().split(Regex("\\s+")).first() }) diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index f76f7ce5d0..e1e49fc280 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -381,9 +381,15 @@ fun MnemonicInputField( OutlinedTextField( value = textFieldValue, - onValueChange = { - if (!it.text.contains(WHITESPACE)) textFieldValue = it - onValueChange(it.text) + onValueChange = { newValue -> + when { + !newValue.text.contains(WHITESPACE) -> { + textFieldValue = newValue + onValueChange(newValue.text) + } + + isPastedInput(previous = textFieldValue, new = newValue) -> onValueChange(newValue.text) + } }, textStyle = AppTextStyles.BodySSB, prefix = { @@ -425,6 +431,15 @@ fun MnemonicInputField( ) } +/** + * Whitespace reaches a word field from a paste or from a typed space. Typing commits one character at a time, + * so only a longer insertion is forwarded as a paste and spread across the fields; a typed space is dropped. + */ +internal fun isPastedInput(previous: TextFieldValue, new: TextFieldValue): Boolean { + val keptLength = previous.text.length - previous.selection.length + return new.text.length - keptLength > 1 +} + @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index 2ffd4df7b6..5eb2b0ad86 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import to.bitkit.services.core.Bip39Service import javax.inject.Inject @@ -28,12 +30,15 @@ class RestoreWalletViewModel @Inject constructor( private val _uiState = MutableStateFlow(RestoreWalletUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** Word edits validate off the main thread, so they are serialized to keep an older edit from undoing a newer one. */ + private val wordEditMutex = Mutex() + init { _uiState.update { it.copy(focusedIndex = 0) } - recomputeValidationState() + viewModelScope.launch { recomputeValidationState() } } - private fun recomputeValidationState() = viewModelScope.launch { + private suspend fun recomputeValidationState() { val currentState = _uiState.value val checksumError = currentState.isChecksumErrorVisible() val buttonsEnabled = currentState.areButtonsEnabled() @@ -104,19 +109,21 @@ class RestoreWalletViewModel @Inject constructor( fun onScrollComplete() = _uiState.update { it.copy(scrollToFieldIndex = null) } private fun handlePastedWords(index: Int, pastedText: String) = viewModelScope.launch { - val separators = Regex("\\s+") // any whitespace chars to account for different sources like password managers - val pastedWords = pastedText - .split(separators) - .filter { it.isNotBlank() } - val state = _uiState.value - val isFullPhraseTarget = index == 0 || !state.is24Words && state.hasNoWordsExcept(index) - when (pastedWords.size) { - 0 -> return@launch - WORDS_MAX -> replaceAllWords(pastedWords) - WORDS_MIN if isFullPhraseTarget -> replaceAllWords(pastedWords) - else -> spreadWords(index, pastedWords) + wordEditMutex.withLock { + val separators = Regex("\\s+") // any whitespace chars to account for different sources like password managers + val pastedWords = pastedText + .split(separators) + .filter { it.isNotBlank() } + val state = _uiState.value + val isFullPhraseTarget = index == 0 || !state.is24Words && state.hasNoWordsExcept(index) + when (pastedWords.size) { + 0 -> return@launch + WORDS_MAX -> replaceAllWords(pastedWords) + WORDS_MIN if isFullPhraseTarget -> replaceAllWords(pastedWords) + else -> spreadWords(index, pastedWords) + } + recomputeValidationState() } - recomputeValidationState() } private suspend fun replaceAllWords(pastedWords: List) { @@ -125,15 +132,15 @@ class RestoreWalletViewModel @Inject constructor( .map { it.index } .toSet() - val newWords = _uiState.value.words.toMutableList().apply { - pastedWords.forEachIndexed { index, word -> this[index] = word } - for (index in pastedWords.size until WORDS_MAX) { - this[index] = "" + _uiState.update { state -> + val newWords = state.words.toMutableList().apply { + pastedWords.forEachIndexed { index, word -> this[index] = word } + for (index in pastedWords.size until WORDS_MAX) { + this[index] = "" + } } - } - _uiState.update { - it.copy( + state.copy( words = newWords.toImmutableList(), invalidWordIndices = invalidIndices.toImmutableSet(), is24Words = pastedWords.size == WORDS_MAX, @@ -176,24 +183,28 @@ class RestoreWalletViewModel @Inject constructor( } private fun updateWordValidity(index: Int, value: String) = viewModelScope.launch { - val newWords = _uiState.value.words.toMutableList().apply { - this[index] = value - } - - val newInvalidIndices = _uiState.value.invalidWordIndices.toMutableSet() - if (!bip39Service.isValidWord(value) && value.isNotEmpty()) { - newInvalidIndices.add(index) - } else { - newInvalidIndices.remove(index) - } - - _uiState.update { - it.copy( - words = newWords.toImmutableList(), - invalidWordIndices = newInvalidIndices.toImmutableSet(), - ) + wordEditMutex.withLock { + val isValid = bip39Service.isValidWord(value) + + _uiState.update { state -> + val newWords = state.words.toMutableList().apply { + this[index] = value + } + + val newInvalidIndices = state.invalidWordIndices.toMutableSet() + if (!isValid && value.isNotEmpty()) { + newInvalidIndices.add(index) + } else { + newInvalidIndices.remove(index) + } + + state.copy( + words = newWords.toImmutableList(), + invalidWordIndices = newInvalidIndices.toImmutableSet(), + ) + } + recomputeValidationState() } - recomputeValidationState() } private fun updateSuggestions(input: String, index: Int?) = viewModelScope.launch { diff --git a/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt b/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt new file mode 100644 index 0000000000..49010c2fe7 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt @@ -0,0 +1,42 @@ +package to.bitkit.ui.onboarding + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MnemonicInputPasteTest { + + @Test + fun `space typed inside a word should not count as a paste`() { + val previous = TextFieldValue("abandon", TextRange(4)) + val new = TextFieldValue("aban don", TextRange(5)) + + assertFalse(isPastedInput(previous = previous, new = new)) + } + + @Test + fun `space typed after a word should not count as a paste`() { + val previous = TextFieldValue("abandon", TextRange(7)) + val new = TextFieldValue("abandon ", TextRange(8)) + + assertFalse(isPastedInput(previous = previous, new = new)) + } + + @Test + fun `fragment pasted into an empty field should count as a paste`() { + val previous = TextFieldValue() + val new = TextFieldValue("abandon ability able", TextRange(20)) + + assertTrue(isPastedInput(previous = previous, new = new)) + } + + @Test + fun `fragment pasted over a selected word should count as a paste`() { + val previous = TextFieldValue("abandon", TextRange(0, 7)) + val new = TextFieldValue("ab cd", TextRange(5)) + + assertTrue(isPastedInput(previous = previous, new = new)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt index 8c786693e2..e8807180fc 100644 --- a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -1,10 +1,13 @@ package to.bitkit.viewmodels +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.services.core.Bip39Service @@ -426,6 +429,26 @@ class RestoreWalletViewModelTest : BaseUnitTest() { assertEquals("abandon", state.words[0]) } + @Test + fun `word typed while a paste validates should not be undone by the paste`() = test { + val validation = CompletableDeferred() + whenever(bip39Service.isValidWord("w1")).doSuspendableAnswer { + validation.await() + true + } + + viewModel.onChangeWord(0, "w1 w2 w3") + viewModel.onChangeWord(0, "typed") + assertEquals("", viewModel.uiState.value.words[0]) + + validation.complete(Unit) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("typed", state.words[0]) + assertEquals(listOf("w2", "w3"), state.words.subList(1, 3)) + } + @Test fun `handlePastedWords should focus first empty field when fragment fills the tail`() { for (i in 3 until 9) { From 097ab79a9451e20b0432d9296718a3889d237b2a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 17 Sep 2026 21:05:52 -0300 Subject: [PATCH 05/10] style: break lines over 120 chars in RestoreWalletViewModel Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/viewmodels/RestoreWalletViewModel.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index 5eb2b0ad86..e4adf3d0d8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -30,7 +30,9 @@ class RestoreWalletViewModel @Inject constructor( private val _uiState = MutableStateFlow(RestoreWalletUiState()) val uiState: StateFlow = _uiState.asStateFlow() - /** Word edits validate off the main thread, so they are serialized to keep an older edit from undoing a newer one. */ + /** + * Word edits validate off the main thread, so they are serialized to keep an older edit from undoing a newer one. + */ private val wordEditMutex = Mutex() init { @@ -110,7 +112,8 @@ class RestoreWalletViewModel @Inject constructor( private fun handlePastedWords(index: Int, pastedText: String) = viewModelScope.launch { wordEditMutex.withLock { - val separators = Regex("\\s+") // any whitespace chars to account for different sources like password managers + // any whitespace chars to account for different sources like password managers + val separators = Regex("\\s+") val pastedWords = pastedText .split(separators) .filter { it.isNotBlank() } From 9c9d483de9d218692c48ea3de02ea8c2ea391e14 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 08:20:02 -0300 Subject: [PATCH 06/10] fix: focus invalid seed field after paste Co-Authored-By: Claude Opus 5 (1M context) --- .../viewmodels/RestoreWalletViewModel.kt | 9 ++-- .../viewmodels/RestoreWalletViewModelTest.kt | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index e4adf3d0d8..b6cfd21188 100644 --- a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -148,7 +148,7 @@ class RestoreWalletViewModel @Inject constructor( invalidWordIndices = invalidIndices.toImmutableSet(), is24Words = pastedWords.size == WORDS_MAX, shouldDismissKeyboard = invalidIndices.isEmpty(), - focusedIndex = null, + focusedIndex = invalidIndices.minOrNull(), suggestions = persistentListOf(), ) } @@ -172,14 +172,15 @@ class RestoreWalletViewModel @Inject constructor( val wordCount = if (is24Words) WORDS_MAX else WORDS_MIN val nextEmptyIndex = (lastWrittenIndex + 1 until wordCount).firstOrNull { newWords[it].isEmpty() } ?: (0 until wordCount).firstOrNull { newWords[it].isEmpty() } + val nextFocusIndex = nextEmptyIndex ?: (0 until wordCount).firstOrNull { it in newInvalidIndices } state.copy( words = newWords.toImmutableList(), invalidWordIndices = newInvalidIndices.toImmutableSet(), is24Words = is24Words, - shouldDismissKeyboard = nextEmptyIndex == null && newInvalidIndices.isEmpty(), - focusedIndex = nextEmptyIndex, - scrollToFieldIndex = nextEmptyIndex ?: lastWrittenIndex, + shouldDismissKeyboard = nextFocusIndex == null && newInvalidIndices.isEmpty(), + focusedIndex = nextFocusIndex, + scrollToFieldIndex = nextFocusIndex ?: lastWrittenIndex, suggestions = persistentListOf(), ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt index e8807180fc..d145fb6087 100644 --- a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -474,6 +474,34 @@ class RestoreWalletViewModelTest : BaseUnitTest() { assertTrue(state.areButtonsEnabled) } + @Test + fun `handlePastedWords should focus invalid field when fragment fills the tail`() = runBlocking { + whenever(bip39Service.isValidWord("zzzz")).thenReturn(false) + for (i in 0 until 9) { + viewModel.onChangeWord(i, "word$i") + } + + viewModel.onChangeWord(9, "zzzz ability able") + + val state = viewModel.uiState.value + assertEquals(9, state.focusedIndex) + assertEquals(9, state.scrollToFieldIndex) + assertEquals(setOf(9), state.invalidWordIndices) + assertFalse(state.shouldDismissKeyboard) + } + + @Test + fun `handlePastedWords should focus first invalid field of a full paste`() = runBlocking { + whenever(bip39Service.isValidWord("zzzz")).thenReturn(false) + val pastedWords = List(12) { if (it == 4) "zzzz" else "w${it + 1}" }.joinToString(" ") + + viewModel.onChangeWord(0, pastedWords) + + val state = viewModel.uiState.value + assertEquals(4, state.focusedIndex) + assertFalse(state.shouldDismissKeyboard) + } + // endregion // region Focus Management @@ -534,6 +562,27 @@ class RestoreWalletViewModelTest : BaseUnitTest() { assertTrue(state.suggestions.isEmpty()) } + @Test + fun `updateSuggestions should resolve for field focused after a tail filling paste`() = runBlocking { + whenever(bip39Service.isValidWord("zzzz")).thenReturn(false) + whenever(bip39Service.getSuggestions("abi", 3u)).thenReturn(listOf("ability", "abandon")) + for (i in 0 until 9) { + viewModel.onChangeWord(i, "word$i") + } + viewModel.onChangeWord(9, "zzzz ability able") + + viewModel.onChangeWord(9, "abi") + + assertEquals(listOf("ability", "abandon"), viewModel.uiState.value.suggestions) + + viewModel.onSelectSuggestion("ability") + + val state = viewModel.uiState.value + assertEquals("ability", state.words[9]) + assertTrue(state.suggestions.isEmpty()) + assertTrue(state.invalidWordIndices.isEmpty()) + } + @Test fun `onSelectSuggestion should apply suggestion to focused word`() { viewModel.onChangeWordFocus(0, true) From 66f7019366610be5e252d51d1e61841a7c005d93 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 09:10:14 -0300 Subject: [PATCH 07/10] fix: refocus seed field when paste switches to 24 words Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index e1e49fc280..d03456bf78 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -152,7 +152,7 @@ private fun Content( } } - LaunchedEffect(uiState.focusedIndex) { + LaunchedEffect(uiState.focusedIndex, uiState.wordCount) { uiState.focusedIndex?.let { index -> focusRequesters[index].requestFocus() } From 89920b350afcaa9328f836af9ee1974f5087f236 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:40:16 -0300 Subject: [PATCH 08/10] docs: add paste seed fragment journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 ++ .../restore-wallet/paste-seed-fragment.xml | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 journeys/restore-wallet/paste-seed-fragment.xml diff --git a/journeys/README.md b/journeys/README.md index 403c5e2309..5e7656aa0e 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -121,6 +121,7 @@ fixtures, push notifications) live in each suite's README. | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required | +| [restore-wallet](restore-wallet) | 1 | Pasting a seed fragment on Restore wallet; needs a wallet-free device; no README | | [widgets](widgets) | 2 | Needs no backend — the quickest way to see the loop work; no README | ## Cross-platform @@ -141,6 +142,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/usb-reconnect.xml` | `reconnect.xml` — over Bridge, since iOS cannot do WebUSB | | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | +| `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | diff --git a/journeys/restore-wallet/paste-seed-fragment.xml b/journeys/restore-wallet/paste-seed-fragment.xml new file mode 100644 index 0000000000..92e9f5f741 --- /dev/null +++ b/journeys/restore-wallet/paste-seed-fragment.xml @@ -0,0 +1,22 @@ + + Proves #896: pasting part of a recovery phrase on Restore wallet spreads the words across the word fields, and every field stays editable with Backspace. Precondition: a throwaway emulator with no wallet (Restore is reachable only from onboarding; never run this on a device holding a wallet you need). The clipboard must hold the BIP39 test words "abandon abandon abandon" before step 6; step 13 copies "abandon abandon abandon abandon abandon abandon abandon abandon about". `adb shell cmd clipboard` is not implemented on the emulator image, so copy the text from another app (for example a note or a Chrome page) first. Use only these public test words. Paste is sent as KEYCODE_PASTE (279) to the focused field. + + Launch to.bitkit.dev on a device with no wallet + Verify that the terms screen shows "Check1" and "Check2", then tap "Continue" — the two blocks are text, not toggles, and "Continue" is always enabled + Tap "SkipIntro" + Tap "RestoreWallet" + Tap "MultipleDevices-button" + Verify that the Restore wallet screen shows word fields "Word-0" to "Word-11", all empty + Tap "Word-0" and run `adb shell input keyevent 279` + Verify that "Word-0", "Word-1" and "Word-2" each show "abandon", "Word-3" is empty and focused, and "Word-0" does not show the whole pasted text + Run `adb shell input keyevent 67` + Verify that "Word-2" is focused and still shows "abandon" + Run `adb shell input keyevent 67` + Verify that "Word-2" shows "abando", so Backspace deletes from the end of the pasted word + Run `adb shell input text n` + Copy the nine-word test text to the clipboard in the other app, return to Bitkit, tap "Word-3" and run `adb shell input keyevent 279` + Verify that "Word-0" to "Word-10" show "abandon", "Word-11" shows "about", no word field is focused and the keyboard is hidden + Verify that "RestoreButton" is enabled, and do not tap it + Press the device back button + + From 4403315af1aa7882749c012a2c96fcc8998611d1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 11:57:25 -0300 Subject: [PATCH 09/10] docs: give paste seed journey a clipboard route Co-Authored-By: Claude Opus 5 (1M context) --- journeys/restore-wallet/paste-seed-fragment.xml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/journeys/restore-wallet/paste-seed-fragment.xml b/journeys/restore-wallet/paste-seed-fragment.xml index 92e9f5f741..bb12ec8e8a 100644 --- a/journeys/restore-wallet/paste-seed-fragment.xml +++ b/journeys/restore-wallet/paste-seed-fragment.xml @@ -1,6 +1,7 @@ - Proves #896: pasting part of a recovery phrase on Restore wallet spreads the words across the word fields, and every field stays editable with Backspace. Precondition: a throwaway emulator with no wallet (Restore is reachable only from onboarding; never run this on a device holding a wallet you need). The clipboard must hold the BIP39 test words "abandon abandon abandon" before step 6; step 13 copies "abandon abandon abandon abandon abandon abandon abandon abandon about". `adb shell cmd clipboard` is not implemented on the emulator image, so copy the text from another app (for example a note or a Chrome page) first. Use only these public test words. Paste is sent as KEYCODE_PASTE (279) to the focused field. + Proves #896: pasting part of a recovery phrase on Restore wallet spreads the words across the word fields, and every field stays editable with Backspace. Precondition: a throwaway emulator with no wallet (Restore is reachable only from onboarding; never run this on a device holding a wallet you need). Both pasted strings are BIP39 test vectors — "abandon abandon abandon" and "abandon abandon abandon abandon abandon abandon abandon abandon about". Use only these public test words, never a real wallet's phrase. Neither string can be put on the clipboard from the device: `adb shell cmd clipboard` is not implemented on the emulator image and `adb shell input text` drops characters from long strings (journeys/README.md:78-79), and a dropped character would fail the word assertions as if the app were at fault. So the clipboard is filled on the host and the emulator picks it up over clipboard sharing (Extended controls > Settings > General > "Enable clipboard sharing", on by default). Each copy action below gives the host command and is followed by a check that the words arrived whole; a short or empty paste there is a clipboard setup failure, not an app failure — re-copy and repeat the paste before reading it as a journey failure. Paste is sent as KEYCODE_PASTE (279) to the focused field. + On the host, run `printf 'abandon abandon abandon' | pbcopy` (macOS) or `printf 'abandon abandon abandon' | xclip -selection clipboard` (Linux), then give the emulator window focus by clicking its title bar — not the device screen — so the shared clipboard reaches the guest Launch to.bitkit.dev on a device with no wallet Verify that the terms screen shows "Check1" and "Check2", then tap "Continue" — the two blocks are text, not toggles, and "Continue" is always enabled Tap "SkipIntro" @@ -8,14 +9,14 @@ Tap "MultipleDevices-button" Verify that the Restore wallet screen shows word fields "Word-0" to "Word-11", all empty Tap "Word-0" and run `adb shell input keyevent 279` - Verify that "Word-0", "Word-1" and "Word-2" each show "abandon", "Word-3" is empty and focused, and "Word-0" does not show the whole pasted text + Verify that "Word-0", "Word-1" and "Word-2" each show "abandon", "Word-3" is empty and focused, and "Word-0" does not show the whole pasted text — if the three fields are empty, or any of them shows a truncated word such as "abandn", the clipboard did not cross from the host whole: re-copy on the host, tap "Word-0" and paste again, and report a clipboard setup failure rather than an app failure if it repeats Run `adb shell input keyevent 67` Verify that "Word-2" is focused and still shows "abandon" Run `adb shell input keyevent 67` Verify that "Word-2" shows "abando", so Backspace deletes from the end of the pasted word Run `adb shell input text n` - Copy the nine-word test text to the clipboard in the other app, return to Bitkit, tap "Word-3" and run `adb shell input keyevent 279` - Verify that "Word-0" to "Word-10" show "abandon", "Word-11" shows "about", no word field is focused and the keyboard is hidden + On the host, run `printf 'abandon abandon abandon abandon abandon abandon abandon abandon about' | pbcopy` (or the `xclip -selection clipboard` equivalent) and give the emulator window focus by clicking its title bar, then tap "Word-3" and run `adb shell input keyevent 279` — the app stays on the Restore wallet screen throughout, so nothing has to be typed on the device + Verify that "Word-0" to "Word-10" show "abandon", "Word-11" shows "about", no word field is focused and the keyboard is hidden — a truncated word or a short field count here means the nine-word copy did not cross whole, which is a clipboard setup failure: re-copy on the host and repeat the paste before reading it as an app failure Verify that "RestoreButton" is enabled, and do not tap it Press the device back button From d3ade084336e542c834670e2510a498858e2781f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 15:10:28 -0300 Subject: [PATCH 10/10] fix: drop the field's own word from a pasted fragment Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/onboarding/RestoreWalletScreen.kt | 15 ++++++++- .../ui/onboarding/MnemonicInputPasteTest.kt | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index d03456bf78..81a9d90222 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -388,7 +388,8 @@ fun MnemonicInputField( onValueChange(newValue.text) } - isPastedInput(previous = textFieldValue, new = newValue) -> onValueChange(newValue.text) + isPastedInput(previous = textFieldValue, new = newValue) -> + onValueChange(insertedText(previous = textFieldValue, new = newValue)) } }, textStyle = AppTextStyles.BodySSB, @@ -440,6 +441,18 @@ internal fun isPastedInput(previous: TextFieldValue, new: TextFieldValue): Boole return new.text.length - keptLength > 1 } +/** + * The text a paste actually inserted, without the field content it was dropped next to. A paste into a field that + * already holds a word arrives glued to it (`about` + `about ...` reads as `aboutabout ...`), and that merged first + * word would then be spread into the fields as if it were pasted. + */ +internal fun insertedText(previous: TextFieldValue, new: TextFieldValue): String { + val prefixLength = previous.selection.min + val suffixLength = previous.text.length - previous.selection.max + val end = (new.text.length - suffixLength).coerceAtLeast(prefixLength) + return new.text.substring(prefixLength.coerceAtMost(new.text.length), end.coerceAtMost(new.text.length)) +} + @Preview(showSystemUi = true) @Composable private fun Preview() { diff --git a/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt b/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt index 49010c2fe7..628053c242 100644 --- a/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt +++ b/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.onboarding import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -39,4 +40,36 @@ class MnemonicInputPasteTest { assertTrue(isPastedInput(previous = previous, new = new)) } + + @Test + fun `fragment pasted into an empty field should be forwarded whole`() { + val previous = TextFieldValue() + val new = TextFieldValue("abandon ability able", TextRange(20)) + + assertEquals("abandon ability able", insertedText(previous = previous, new = new)) + } + + @Test + fun `fragment pasted after a filled word should drop that word`() { + val previous = TextFieldValue("about", TextRange(5)) + val new = TextFieldValue("aboutabout abandon art", TextRange(22)) + + assertEquals("about abandon art", insertedText(previous = previous, new = new)) + } + + @Test + fun `fragment pasted before a filled word should drop that word`() { + val previous = TextFieldValue("about", TextRange(0)) + val new = TextFieldValue("abandon artabout", TextRange(11)) + + assertEquals("abandon art", insertedText(previous = previous, new = new)) + } + + @Test + fun `fragment pasted over a selected word should drop that word`() { + val previous = TextFieldValue("abandon", TextRange(0, 7)) + val new = TextFieldValue("about art", TextRange(9)) + + assertEquals("about art", insertedText(previous = previous, new = new)) + } }