diff --git a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt index 7a4e0b24f1..124ed07631 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt @@ -147,7 +147,7 @@ class AmountInputViewModel @Inject constructor( // Update raw input text based on the formatted display rawInputText = when (primaryDisplay) { PrimaryDisplay.FIAT -> _uiState.value.text.replace(",", "") - else -> _uiState.value.text + else -> _uiState.value.text.stripSatsGrouping() } } @@ -171,7 +171,7 @@ class AmountInputViewModel @Inject constructor( // Update raw input text based on the new display rawInputText = when (newPrimaryDisplay) { PrimaryDisplay.FIAT -> _uiState.value.text.replace(",", "") - else -> _uiState.value.text + else -> _uiState.value.text.stripSatsGrouping() } } else if (currentRawInput.isNotEmpty()) { // Convert the raw input from the old currency to the new currency @@ -190,8 +190,9 @@ class AmountInputViewModel @Inject constructor( // Converting from fiat to bitcoin val sats = convertFiatToSats(currentRawInput) if (sats != null) { - rawInputText = formatBitcoinFromSats(sats, isModern) - _uiState.update { it.copy(text = rawInputText) } + val formatted = formatBitcoinFromSats(sats, isModern) + rawInputText = formatted.stripSatsGrouping() + _uiState.update { it.copy(text = formatted) } } } } @@ -335,6 +336,8 @@ class AmountInputViewModel @Inject constructor( return if (isModern) sats.formatToModernDisplay() else sats.formatToClassicDisplay() } + private fun String.stripSatsGrouping(): String = replace("$SATS_GROUPING_SEPARATOR", "") + private fun convertToSats( text: String, primaryDisplay: PrimaryDisplay, @@ -351,7 +354,7 @@ class AmountInputViewModel @Inject constructor( if (text.isEmpty()) return 0 return if (isModern) { - text.replace("$SATS_GROUPING_SEPARATOR", "").toLongOrDefault() + text.stripSatsGrouping().toLongOrDefault() } else { runCatching { val btcBigDecimal = BigDecimal(text) diff --git a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt index 32edff9dd7..f131bf1d1c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt @@ -23,6 +23,7 @@ import to.bitkit.models.FIAT_DECIMALS import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay import to.bitkit.models.STUB_RATE +import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.CurrencyState import to.bitkit.services.CurrencyService @@ -365,6 +366,62 @@ class AmountInputViewModelTest : BaseUnitTest() { assertEquals("12 345", viewModel.uiState.value.text) } + @Test + fun `setSats in modern bitcoin allows digits up to max amount`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setSats(12_345_678L, currency) + assertEquals("12 345 678", viewModel.uiState.value.text) + + viewModel.handleNumberPadInput("9", currency) + + assertEquals(123_456_789L, viewModel.uiState.value.sats) + assertEquals("123 456 789", viewModel.uiState.value.text) + assertNull(viewModel.uiState.value.errorKey) + } + + @Test + fun `setSats in modern bitcoin then delete removes a digit on every press`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setSats(1_234L, currency) + assertEquals("1 234", viewModel.uiState.value.text) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals(123L, viewModel.uiState.value.sats) + assertEquals("123", viewModel.uiState.value.text) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals(12L, viewModel.uiState.value.sats) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals(1L, viewModel.uiState.value.sats) + + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals(0L, viewModel.uiState.value.sats) + assertEquals("", viewModel.uiState.value.text) + } + + @Test + fun `switchUnit from fiat to modern bitcoin accepts appended digit`() = test { + val fiat = mockCurrency(PrimaryDisplay.FIAT) + val modernBtc = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + "11515".forEach { viewModel.handleNumberPadInput(it.toString(), fiat) } + val satsBefore = viewModel.uiState.value.sats + assertTrue(satsBefore >= 10_000_000L) + + viewModel.switchUnit(fiat) + assertEquals(satsBefore, viewModel.uiState.value.sats) + + viewModel.handleNumberPadInput("1", modernBtc) + + val expectedSats = satsBefore * 10 + 1 + assertEquals(expectedSats, viewModel.uiState.value.sats) + assertEquals(expectedSats.formatToModernDisplay(), viewModel.uiState.value.text) + assertNull(viewModel.uiState.value.errorKey) + } + @Test fun `setSats works with fiat currency`() = test { val currency = mockCurrency(PrimaryDisplay.FIAT) diff --git a/changelog.d/next/558.fixed.md b/changelog.d/next/558.fixed.md new file mode 100644 index 0000000000..28391aac40 --- /dev/null +++ b/changelog.d/next/558.fixed.md @@ -0,0 +1 @@ +Fixed the sats amount keypad blocking extra digits and ignoring delete presses after using a preset amount or switching units. diff --git a/journeys/README.md b/journeys/README.md index 0862fbbbae..5dd5f08f7d 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -117,6 +117,7 @@ fixtures, push notifications) live in each suite's README. | [activity](activity) | 1 | Date range sheet under rapid month taps; needs no backend, no README | | [amount-limits](amount-limits) | 4 | Number pad caps on all four amount screens | | [backup-restore](backup-restore) | 1 | VSS restore keeps tags and closed channels; wipes the wallet | +| [amount-limits](amount-limits) | 5 | Number pad caps on all four amount screens, plus preset/unit-switch delete | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | @@ -154,6 +155,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `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 | +| `amount-limits/transfer-spending-preset-delete.xml` | not ported yet — the same fix shipped in synonymdev/bitkit-ios#289, so this one should port | | `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | | `security/pin-result-long-label.xml` | not ported — the toggle exists on the iOS security success screen, but the overlap check is a follow-up | | `tags/activity-tag-length-cap.xml` | not ported — iOS has no 20-character cap on tag input | diff --git a/journeys/amount-limits/transfer-spending-preset-delete.xml b/journeys/amount-limits/transfer-spending-preset-delete.xml new file mode 100644 index 0000000000..cda6165264 --- /dev/null +++ b/journeys/amount-limits/transfer-spending-preset-delete.xml @@ -0,0 +1,47 @@ + + + Verifies that after the "Transfer to Spending" amount is filled by a preset (25% or MAX) or by + switching the number pad unit from fiat, every delete press removes exactly one digit and + appended digits are accepted. The displayed amount keeps its sats grouping spaces, but the + number pad input underneath does not, so no press is spent on a space (synonymdev/bitkit-android#558). + + Precondition: onboarded dev wallet with the display unit set to modern Bitcoin (sats), primary + display Bitcoin, a POSITIVE on-chain Savings balance of at least 250 000 sats, LSP limits above + that, and a running node connected to the LSP. Start on the wallet home screen. No funds move: + the journey never taps Continue. + + Why 250 000: the number pad rejects any sats input above its max + (AmountInputViewModel.handleNumberPadInput, the guard comparing the new amount to maxAmount), + and that max is `maxAllowedToSend` — the Savings balance less the quoted LSP order fee, also + capped by the LSP's own max client balance (TransferViewModel, SpendingAmountScreen's + `setMaxAmount`). It is the value shown by "SpendingAmountUnit". Entering 10 fiat units and + switching back to sats gives about 10 000 sats at a BTC price near 100 000 fiat units, and the + append in the step below makes that about 100 001, so a smaller balance has the digit dropped + and the max toast shown instead, which false-fails the step. The guard in the fiat-entry step + below keeps that true if the price moves far enough for 10 fiat units to be worth more than a + tenth of the max. + + iOS counterpart: the same fix shipped in synonymdev/bitkit-ios#289, but the journey is not + ported to the iOS corpus yet. + + + Tap the Savings balance card (testTag "ActivitySavings") on the home screen + Tap "Transfer To Spending" (testTag "TransferToSpending") + If the spending intro screen appears, tap "Get Started" (testTag "SpendingIntro-button") + Verify the spending amount screen (testTag "SpendingAmount") is visible + Wait until the available amount (testTag "SpendingAmountUnit") finishes loading and shows a positive value + Tap the 25% button (testTag "SpendingAmountQuarter") + Verify the amount in the input field (testTag "SpendingAmountNumberField") shows a grouped value with a space, e.g. "78 777" + Tap the delete key (testTag "NRemove") repeatedly until the amount is 0, reading the amount after each press + Verify every delete press changed the amount, e.g. "78 777" → "7 877" → "787" → "78" → "7" → "0", with no press leaving the amount unchanged + Tap the number pad unit toggle (testTag "SpendingNumberPadUnit") so it shows the fiat currency + Tap "1" (testTag "N1") and "0" (testTag "N0") to enter 10 fiat units; if 10 fiat units are worth more than a tenth of the amount shown in "SpendingAmountUnit", tap only "1" (testTag "N1") instead, so the digit appended below still fits under that amount + Tap the number pad unit toggle (testTag "SpendingNumberPadUnit") so it shows "BITCOIN" + Verify the amount in the input field is a grouped sats value of at least 4 digits + Tap "1" (testTag "N1") + Verify the amount gained exactly one trailing digit "1", is not over the amount shown in "SpendingAmountUnit", and that no "Spending Balance Maximum" warning toast appeared + Tap the delete key (testTag "NRemove") repeatedly until the amount is 0, reading the amount after each press + Verify every delete press changed the amount + Tap the back button (testTag "NavigationBack") to leave without transferring + +