Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String>()
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)
}
}
47 changes: 39 additions & 8 deletions app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -148,7 +152,7 @@ private fun Content(
}
}

LaunchedEffect(uiState.focusedIndex) {
LaunchedEffect(uiState.focusedIndex, uiState.wordCount) {
uiState.focusedIndex?.let { index ->
focusRequesters[index].requestFocus()
}
Expand Down Expand Up @@ -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 = {
Expand All @@ -405,7 +415,7 @@ fun MnemonicInputField(
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.Backspace &&
keyEvent.type == KeyEventType.KeyDown &&
value.isEmpty()
textFieldValue.text.isEmpty()
) {
onBackspaceInEmpty()
true
Expand All @@ -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() {
Expand Down
Loading
Loading