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..77da7be4ae --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/onboarding/MnemonicInputFieldTest.kt @@ -0,0 +1,148 @@ +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 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() }) + + composeTestRule.onNodeWithTag(FIELD_TAG).performTextInput("abandon ability able") + composeTestRule.waitForIdle() + + 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() + 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/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index f58f184397..81a9d90222 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 @@ -76,6 +77,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, @@ -148,7 +152,7 @@ private fun Content( } } - LaunchedEffect(uiState.focusedIndex) { + LaunchedEffect(uiState.focusedIndex, uiState.wordCount) { uiState.focusedIndex?.let { index -> focusRequesters[index].requestFocus() } @@ -368,19 +372,25 @@ 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)) } } OutlinedTextField( value = textFieldValue, - onValueChange = { - textFieldValue = it - onValueChange(it.text) + onValueChange = { newValue -> + when { + !newValue.text.contains(WHITESPACE) -> { + textFieldValue = newValue + onValueChange(newValue.text) + } + + isPastedInput(previous = textFieldValue, new = newValue) -> + onValueChange(insertedText(previous = textFieldValue, new = newValue)) + } }, textStyle = AppTextStyles.BodySSB, prefix = { @@ -405,7 +415,7 @@ fun MnemonicInputField( .onPreviewKeyEvent { keyEvent -> if (keyEvent.key == Key.Backspace && keyEvent.type == KeyEventType.KeyDown && - value.isEmpty() + textFieldValue.text.isEmpty() ) { onBackspaceInEmpty() true @@ -422,6 +432,27 @@ 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 +} + +/** + * 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/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt index 4e4a8815eb..b6cfd21188 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,17 @@ 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() @@ -48,7 +55,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,57 +110,105 @@ class RestoreWalletViewModel @Inject constructor( fun onScrollComplete() = _uiState.update { it.copy(scrollToFieldIndex = null) } - private fun handlePastedWords(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 { + private fun handlePastedWords(index: Int, pastedText: String) = viewModelScope.launch { + wordEditMutex.withLock { + // any whitespace chars to account for different sources like password managers + val separators = Regex("\\s+") + 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() + } + } + + private suspend fun replaceAllWords(pastedWords: List) { + val invalidIndices = pastedWords.withIndex() + .filter { !bip39Service.isValidWord(it.value) } + .map { it.index } + .toSet() + + _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( - words = newWords.toImmutableList(), - invalidWordIndices = invalidIndices.toImmutableSet(), - is24Words = pastedWords.size == WORDS_MAX, - shouldDismissKeyboard = invalidIndices.isEmpty(), - focusedIndex = null, - suggestions = persistentListOf(), - ) - } - recomputeValidationState() + state.copy( + words = newWords.toImmutableList(), + invalidWordIndices = invalidIndices.toImmutableSet(), + is24Words = pastedWords.size == WORDS_MAX, + shouldDismissKeyboard = invalidIndices.isEmpty(), + focusedIndex = invalidIndices.minOrNull(), + suggestions = persistentListOf(), + ) } } - private fun updateWordValidity(index: Int, value: String) = viewModelScope.launch { - val newWords = _uiState.value.words.toMutableList().apply { - this[index] = value - } + 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 - val newInvalidIndices = _uiState.value.invalidWordIndices.toMutableSet() - if (!bip39Service.isValidWord(value) && value.isNotEmpty()) { - newInvalidIndices.add(index) - } else { - newInvalidIndices.remove(index) - } + _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) + } - _uiState.update { - it.copy( + 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() } + val nextFocusIndex = nextEmptyIndex ?: (0 until wordCount).firstOrNull { it in newInvalidIndices } + + state.copy( words = newWords.toImmutableList(), invalidWordIndices = newInvalidIndices.toImmutableSet(), + is24Words = is24Words, + shouldDismissKeyboard = nextFocusIndex == null && newInvalidIndices.isEmpty(), + focusedIndex = nextFocusIndex, + scrollToFieldIndex = nextFocusIndex ?: lastWrittenIndex, + suggestions = persistentListOf(), ) } - recomputeValidationState() + } + + private fun updateWordValidity(index: Int, value: String) = viewModelScope.launch { + 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() + } } private fun updateSuggestions(input: String, index: Int?) = viewModelScope.launch { @@ -172,6 +227,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/ui/onboarding/MnemonicInputPasteTest.kt b/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt new file mode 100644 index 0000000000..628053c242 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/onboarding/MnemonicInputPasteTest.kt @@ -0,0 +1,75 @@ +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 + +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)) + } + + @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)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt index e8ee09a42f..d145fb6087 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 @@ -268,6 +271,237 @@ class RestoreWalletViewModelTest : BaseUnitTest() { assertFalse(state.shouldDismissKeyboard) } + @Test + 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) + + 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 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") + + 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 `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) { + 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) + } + + @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 @@ -328,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) 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. diff --git a/journeys/README.md b/journeys/README.md index c711711637..ee9686f31b 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -127,6 +127,7 @@ fixtures, push notifications) live in each suite's README. | [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 | | [receive](receive) | 1 | Receive sheet tab selection; needs a spending channel, no README | +| [restore-wallet](restore-wallet) | 1 | Pasting a seed fragment on Restore wallet; needs a wallet-free device; no README | | [security](security) | 1 | PIN result sheet layout at a long locale and font scale; no README | | [shop](shop) | 1 | Shop Discover category titles and web view handoff; needs Bitrefill reachable; no README | | [subscriptions](subscriptions) | 4 | Paykit subscription lifecycle across two wallets, plus the Payments tab | @@ -155,6 +156,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `coin-selection/manual-coin-selection.xml` | not ported — iOS has the screen (`SendUtxoSelectionView`) but no accessibility identifiers on it yet | | `payment-requests/requested-resolution-failure.xml` | not ported | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | +| `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 | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | diff --git a/journeys/restore-wallet/paste-seed-fragment.xml b/journeys/restore-wallet/paste-seed-fragment.xml new file mode 100644 index 0000000000..bb12ec8e8a --- /dev/null +++ b/journeys/restore-wallet/paste-seed-fragment.xml @@ -0,0 +1,23 @@ + + 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" + 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 — 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` + 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 + +