From 068fb917d7c09f0d62ed014721183954671698b3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 14:18:33 +0200 Subject: [PATCH 01/29] fix: require payment pin for quickpay --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 ++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 21 ++++++++++++++++--- .../next/require-payment-pin.security.md | 1 + 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 changelog.d/next/require-payment-pin.security.md diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 849726cc2..842bf4fc8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,6 +74,7 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -2603,7 +2604,7 @@ class AppViewModel @Inject constructor( if (hasActiveContactPaymentContext()) return false val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + if (!canApplyQuickPay(settings, amountSats)) return false val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false @@ -2636,6 +2637,15 @@ class AppViewModel @Inject constructor( return false } + private fun canApplyQuickPay(settings: SettingsData, amountSats: ULong): Boolean { + if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { + Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) + return false + } + return true + } + private fun resetAmountInput() { _sendUiState.update { state -> state.copy( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6822141c6..1fbd60d6a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2155,7 +2155,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `lightning scan uses QuickPay when PIN is required for payments`() = test { + fun `lightning scan skips QuickPay when PIN is required for payments`() = test { val bolt11 = "lnbcrt1quickpaypin" enableQuickPay(thresholdSats = 1000u) settingsData.value = settingsData.value.copy( @@ -2168,6 +2168,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScanResult(bolt11) advanceUntilIdle() + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `lightning scan uses QuickPay when PIN is on without PIN for payments`() = test { + val bolt11 = "lnbcrt1quickpayunlocked" + enableQuickPay(thresholdSats = 1000u) + settingsData.value = settingsData.value.copy(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @@ -2192,8 +2207,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) advanceUntilIdle() - assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) verify(coreService).decode(bolt11) } diff --git a/changelog.d/next/require-payment-pin.security.md b/changelog.d/next/require-payment-pin.security.md new file mode 100644 index 000000000..dbb88e6bc --- /dev/null +++ b/changelog.d/next/require-payment-pin.security.md @@ -0,0 +1 @@ +QuickPay now asks for the payment PIN when that setting is enabled. From 76cb9ef2fc367e481baea0bc5448473c800f5589 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 14:26:10 +0200 Subject: [PATCH 02/29] chore: rename changelog fragment --- .../next/{require-payment-pin.security.md => 1159.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{require-payment-pin.security.md => 1159.security.md} (100%) diff --git a/changelog.d/next/require-payment-pin.security.md b/changelog.d/next/1159.security.md similarity index 100% rename from changelog.d/next/require-payment-pin.security.md rename to changelog.d/next/1159.security.md From 01cc20880fe28d478b322cda8dc2e3e762812959 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 15:43:38 +0200 Subject: [PATCH 03/29] fix: add quickpay daily spend limit --- .../main/java/to/bitkit/data/CacheStore.kt | 17 +++++ .../main/java/to/bitkit/data/SettingsStore.kt | 1 + app/src/main/java/to/bitkit/ext/DateTime.kt | 6 ++ .../quickPay/QuickPaySettingsScreen.kt | 35 +++++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 66 ++++++++++--------- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 13 ++++ .../to/bitkit/viewmodels/SettingsViewModel.kt | 9 +++ app/src/main/res/values/strings.xml | 2 + .../java/to/bitkit/data/CacheStoreTest.kt | 25 +++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 48 ++++++++++++-- changelog.d/next/1159.security.md | 2 +- 11 files changed, 185 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index dc337a825..0a8fe8e5d 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,6 +139,21 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } + suspend fun quickPaySpentUsdForDay(dayKey: String): Double { + val data = store.data.first() + return if (data.quickPaySpendDayKey == dayKey) data.quickPaySpentUsdToday else 0.0 + } + + suspend fun recordQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + store.updateData { + if (it.quickPaySpendDayKey != dayKey) { + it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) + } else { + it.copy(quickPaySpentUsdToday = it.quickPaySpentUsdToday + amountUsd) + } + } + } + suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -164,6 +179,8 @@ data class AppCacheData( val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), + val quickPaySpendDayKey: String = "", + val quickPaySpentUsdToday: Double = 0.0, ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 5904e4881..eddec111d 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -128,6 +128,7 @@ data class SettingsData( val bgPaymentsIntroSeen: Boolean = false, val isQuickPayEnabled: Boolean = false, val quickPayAmount: Int = 5, + val quickPayDailyLimitMultiplier: Int = 5, val lightningSetupStep: Int = 0, val isPinEnabled: Boolean = false, val isBiometricEnabled: Boolean = false, diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 8358e3871..145b2908d 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -39,6 +39,12 @@ fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMillisecon @OptIn(ExperimentalTime::class) fun Clock.nowMs(): Long = now().toEpochMilliseconds() +@OptIn(ExperimentalTime::class) +fun quickPaySpendDayKey( + clock: Clock = Clock.System, + timeZone: TimeZone = TimeZone.currentSystemDefault(), +): String = clock.now().toLocalDateTime(timeZone).date.toString() + fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun dateTimeFormatterOf( diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 145bea29e..0ada40b55 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -38,12 +38,15 @@ fun QuickPaySettingsScreen( ) { val isQuickPayEnabled by settingsViewModel.isQuickpayEnabled.collectAsStateWithLifecycle() val quickPayAmount by settingsViewModel.quickPayAmount.collectAsStateWithLifecycle() + val quickPayDailyLimitMultiplier by settingsViewModel.quickPayDailyLimitMultiplier.collectAsStateWithLifecycle() QuickPaySettingsScreenContent( isQuickPayEnabled = isQuickPayEnabled, quickPayAmount = quickPayAmount, + quickPayDailyLimitMultiplier = quickPayDailyLimitMultiplier, onToggleQuickPay = settingsViewModel::setIsQuickPayEnabled, onQuickPayAmountChange = settingsViewModel::setQuickPayAmount, + onQuickPayDailyLimitMultiplierChange = settingsViewModel::setQuickPayDailyLimitMultiplier, onBack = onBack, ) } @@ -52,11 +55,15 @@ fun QuickPaySettingsScreen( fun QuickPaySettingsScreenContent( isQuickPayEnabled: Boolean, quickPayAmount: Int, + quickPayDailyLimitMultiplier: Int, onToggleQuickPay: (Boolean) -> Unit = {}, onQuickPayAmountChange: (Int) -> Unit = {}, + onQuickPayDailyLimitMultiplierChange: (Int) -> Unit = {}, onBack: () -> Unit = {}, ) { val sliderSteps = remember { persistentListOf(1, 5, 10, 20, 50) } + val dailyLimitSteps = remember { persistentListOf(1, 3, 5, 10, 50) } + val dailyLimitUsd = quickPayAmount * quickPayDailyLimitMultiplier ScreenColumn { AppTopBar( @@ -98,7 +105,32 @@ fun QuickPaySettingsScreenContent( value = quickPayAmount, steps = sliderSteps, onValueChange = onQuickPayAmountChange, - modifier = Modifier.testTag("quickpay_amount_slider") + modifier = Modifier.testTag("QuickpayAmountSlider") + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Caption13Up( + text = stringResource(R.string.settings__quickpay__settings__daily_label), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BodyM( + text = stringResource(R.string.settings__quickpay__settings__daily_text) + .replace("{limit}", dailyLimitUsd.toString()) + .replace("{multiplier}", quickPayDailyLimitMultiplier.toString()), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + StepSlider( + value = quickPayDailyLimitMultiplier, + steps = dailyLimitSteps, + onValueChange = onQuickPayDailyLimitMultiplierChange, + modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) Spacer(modifier = Modifier.weight(1f)) @@ -128,6 +160,7 @@ private fun Preview() { QuickPaySettingsScreenContent( isQuickPayEnabled = true, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 842bf4fc8..fb54acf8b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,7 +74,6 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -98,6 +97,7 @@ import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat +import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.runSuspendCatching @@ -2601,48 +2601,52 @@ class AppViewModel @Inject constructor( lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { - if (hasActiveContactPaymentContext()) return false - - val settings = settingsStore.data.first() - if (!canApplyQuickPay(settings, amountSats)) return false - - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() - ?: return false - - if (amountSats <= quickPayAmountSats) { - Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold", context = TAG) + if (!canApplyQuickPay(amountSats)) return false - val quickPayData: QuickPayData = when { - lnurlPay != null -> { - QuickPayData.LnurlPay( - sats = amountSats, - data = lnurlPay, - ) - } + Logger.info("Using QuickPay for '$amountSats' sats", context = TAG) - else -> { - val decodedInvoice = requireNotNull(invoice) - QuickPayData.Bolt11(sats = amountSats, bolt11 = decodedInvoice.bolt11) - } + val quickPayData: QuickPayData = when { + lnurlPay != null -> { + QuickPayData.LnurlPay( + sats = amountSats, + data = lnurlPay, + ) } - _quickPayData.update { quickPayData } + else -> { + val decodedInvoice = requireNotNull(invoice) + QuickPayData.Bolt11(sats = amountSats, bolt11 = decodedInvoice.bolt11) + } + } - Logger.debug("QuickPayData: $quickPayData", context = TAG) + _quickPayData.update { quickPayData } - navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) - return true - } + Logger.debug("QuickPayData: $quickPayData", context = TAG) - return false + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) + return true } - private fun canApplyQuickPay(settings: SettingsData, amountSats: ULong): Boolean { + private suspend fun canApplyQuickPay(amountSats: ULong): Boolean { + if (hasActiveContactPaymentContext()) return false + + val settings = settingsStore.data.first() if (!settings.isQuickPayEnabled || amountSats == 0uL) return false - if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { - Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) + + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + if (quickPayAmountSats == null || amountUsd == null || amountSats > quickPayAmountSats) return false + + val dailyCapUsd = settings.quickPayAmount.toDouble() * settings.quickPayDailyLimitMultiplier + val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) + if (spentUsdToday + amountUsd > dailyCapUsd) { + Logger.info( + "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + context = TAG, + ) return false } + return true } diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 0599d4ae4..e4fb98fa0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -11,10 +11,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.data.CacheStore import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats +import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo @@ -27,6 +30,8 @@ class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, + private val currencyRepo: CurrencyRepo, + private val cacheStore: CacheStore, ) : ViewModel() { companion object { @@ -65,6 +70,7 @@ class QuickPayViewModel @Inject constructor( sendLightning(bolt11, amount) .onSuccess { paymentHash -> Logger.info("QuickPay lightning payment successful") + recordQuickPaySpend(displaySats) _uiState.update { it.copy( result = QuickPayResult.Success( @@ -77,6 +83,7 @@ class QuickPayViewModel @Inject constructor( if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) pendingPaymentRepo.track(error.paymentHash) + recordQuickPaySpend(displaySats) _uiState.update { it.copy( result = QuickPayResult.Pending( @@ -96,6 +103,12 @@ class QuickPayViewModel @Inject constructor( } } + private suspend fun recordQuickPaySpend(amountSats: ULong) { + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return + cacheStore.recordQuickPaySpendUsd(amountUsd, quickPaySpendDayKey()) + } + private suspend fun sendLightning( bolt11: String, amount: ULong? = null, diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 9410f5c5f..e406efd3e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -348,6 +348,15 @@ class SettingsViewModel @Inject constructor( } } + val quickPayDailyLimitMultiplier = settingsStore.data.map { it.quickPayDailyLimitMultiplier } + .asStateFlow(initialValue = 5) + + fun setQuickPayDailyLimitMultiplier(value: Int) { + viewModelScope.launch { + settingsStore.update { it.copy(quickPayDailyLimitMultiplier = value) } + } + } + val enableSwipeToHideBalance = settingsStore.data.map { it.enableSwipeToHideBalance } .asStateFlow(initialValue = true) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0682904c..3cf21d8f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -933,6 +933,8 @@ Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. <accent>Frictionless</accent>\npayments QuickPay + Daily QuickPay limit + Auto-pay up to ${limit} per day without confirmation or PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold * Bitkit QuickPay exclusively supports payments from your Spending Balance. If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 915f35d3b..90855eb81 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -91,4 +91,29 @@ class CacheStoreTest : BaseUnitTest() { sut.data.first().deletedActivities, ) } + + @Test + fun `quickPaySpentUsdForDay returns spend for matching day key`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 3.5, dayKey = "2026-08-15") + + assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `quickPaySpentUsdForDay returns zero for a different day key`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 12.0, dayKey = "2026-08-14") + + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `recordQuickPaySpendUsd accumulates on the same day and resets on a new day`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 2.0, dayKey = "2026-08-15") + sut.recordQuickPaySpendUsd(amountUsd = 1.5, dayKey = "2026-08-15") + assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) + + sut.recordQuickPaySpendUsd(amountUsd = 4.0, dayKey = "2026-08-16") + assertEquals(4.0, sut.quickPaySpentUsdForDay("2026-08-16")) + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 1fbd60d6a..1f7dc0d13 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -60,6 +60,7 @@ import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState +import to.bitkit.models.ConvertedAmount import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -117,8 +118,10 @@ import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager +import java.math.BigDecimal import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -220,6 +223,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(0.0) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever { blocktankRepo.refreshInfo() }.thenReturn(Result.success(Unit)) @@ -2155,7 +2159,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `lightning scan skips QuickPay when PIN is required for payments`() = test { + fun `lightning scan uses QuickPay when PIN is required for payments under daily cap`() = test { val bolt11 = "lnbcrt1quickpaypin" enableQuickPay(thresholdSats = 1000u) settingsData.value = settingsData.value.copy( @@ -2168,8 +2172,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScanResult(bolt11) advanceUntilIdle() - assertNull(sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @Test @@ -2187,6 +2191,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } + @Test + fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { + val bolt11 = "lnbcrt1quickpaycap" + enableQuickPay(thresholdSats = 1000u, spentUsdToday = 24.0) + settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `QuickPay eligible scan remains deferred until authenticated`() = test { val bolt11 = "lnbcrt1lockedscan" @@ -2207,8 +2226,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) advanceUntilIdle() - assertNull(sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) verify(coreService).decode(bolt11) } @@ -3194,9 +3213,26 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - private fun enableQuickPay(thresholdSats: ULong) { + private fun enableQuickPay( + thresholdSats: ULong, + spentUsdToday: Double = 0.0, + ) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> + val sats = invocation.getArgument(0) + val usd = 5.0 * sats.toDouble() / thresholdSats.toDouble() + ConvertedAmount( + value = BigDecimal.valueOf(usd), + formatted = usd.toString(), + symbol = "$", + currency = "USD", + flag = "", + sats = sats, + locale = Locale.US, + ) + } + whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(spentUsdToday) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { diff --git a/changelog.d/next/1159.security.md b/changelog.d/next/1159.security.md index dbb88e6bc..3a5339f9f 100644 --- a/changelog.d/next/1159.security.md +++ b/changelog.d/next/1159.security.md @@ -1 +1 @@ -QuickPay now asks for the payment PIN when that setting is enabled. +QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead. From 99b47d87d0d5378afb2580d5ead8a5bcad5c0163 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 15:52:28 +0200 Subject: [PATCH 04/29] fix: show multiplier steps as times --- app/src/main/java/to/bitkit/ui/components/Slider.kt | 3 ++- .../to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 8c6d12405..fd7f07b6d 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -60,6 +60,7 @@ fun StepSlider( steps: ImmutableList, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, + formatLabel: (Int) -> String = { "$$it" }, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -228,7 +229,7 @@ fun StepSlider( steps.forEachIndexed { index, step -> if (stepPositions.isNotEmpty() && index < stepPositions.size) { Caption13Up( - text = "$$step", + text = formatLabel(step), color = Colors.White64, textAlign = TextAlign.Center, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 0ada40b55..23992e2fe 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -130,6 +130,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, + formatLabel = { "${it}×" }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) From fbe9321e3649bfe51311c4eb0f30bf2189ba1bb6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 16:46:32 +0200 Subject: [PATCH 05/29] fix: include steplider labels in layout bounds --- .../java/to/bitkit/ui/components/Slider.kt | 239 ++++++++++-------- 1 file changed, 128 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index fd7f07b6d..6b353b306 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -117,130 +117,147 @@ fun StepSlider( sliderWidth = coordinates.size.width } ) { - // Track and step markers - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { - detectTapGestures { offset -> - val (closestStep, closestIndex) = findClosestStep(offset.x) - coroutineScope.launch { - knobPosition.animateTo( - targetValue = closestStep, - animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), - ) + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + ) { + // Track and step markers + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + .pointerInput(Unit) { + detectTapGestures { offset -> + val (closestStep, closestIndex) = findClosestStep(offset.x) + coroutineScope.launch { + knobPosition.animateTo( + targetValue = closestStep, + animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), + ) + } + onValueChange(steps[closestIndex]) + } } - onValueChange(steps[closestIndex]) - } - } - ) { - val trackY = center.y - val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } - val cornerRadius = density.run { 3.dp.toPx() } + ) { + val trackY = center.y + val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } + val cornerRadius = density.run { 3.dp.toPx() } - // Draw inactive track - drawRoundRect( - color = Colors.Green32, - topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(size.width, trackHeight), - cornerRadius = CornerRadius(cornerRadius), - ) - - // Draw active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { - drawRoundRect( - color = Colors.Green, - topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), - cornerRadius = CornerRadius(cornerRadius), - ) - } + // Draw inactive track + drawRoundRect( + color = Colors.Green32, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(size.width, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) - // Draw step markers - val markerWidth = density.run { STEP_MARKER_WIDTH_DP.dp.toPx() } - val markerHeight = density.run { STEP_MARKER_HEIGHT_DP.dp.toPx() } - val markerRadius = density.run { 2.5.dp.toPx() } + // Draw active track + val activeWidth = knobPosition.value + if (activeWidth > 0) { + drawRoundRect( + color = Colors.Green, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(activeWidth, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) + } - stepPositions.forEach { position -> - drawRoundRect( - color = Colors.White, - topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), - size = Size(markerWidth, markerHeight), - cornerRadius = CornerRadius(markerRadius), - ) - } - } + // Draw step markers + val markerWidth = density.run { STEP_MARKER_WIDTH_DP.dp.toPx() } + val markerHeight = density.run { STEP_MARKER_HEIGHT_DP.dp.toPx() } + val markerRadius = density.run { 2.5.dp.toPx() } - // Knob - Box( - modifier = Modifier - .offset { - IntOffset( - x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), - y = 0, - ) - } - .size(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { _ -> - // No action needed on drag start - }, - onDragEnd = { - val (closestStep, closestIndex) = findClosestStep(knobPosition.value) - coroutineScope.launch { - knobPosition.animateTo( - targetValue = closestStep, - animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), - ) - } - onValueChange(steps[closestIndex]) - }, - ) { _, dragAmount -> - coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) - knobPosition.snapTo(newPosition) - } + stepPositions.forEach { position -> + drawRoundRect( + color = Colors.White, + topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), + size = Size(markerWidth, markerHeight), + cornerRadius = CornerRadius(markerRadius), + ) } } - ) { - // Outer green circle - Box( - modifier = Modifier - .size(KNOB_SIZE_DP.dp) - .clip(CircleShape) - .background(Colors.Green) - ) { - // Inner white circle - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(Colors.White) - .align(Alignment.Center) - ) - } - } - // Step labels - steps.forEachIndexed { index, step -> - if (stepPositions.isNotEmpty() && index < stepPositions.size) { - Caption13Up( - text = formatLabel(step), - color = Colors.White64, - textAlign = TextAlign.Center, + // Knob + Box( modifier = Modifier - .width(KNOB_SIZE_DP.dp) .offset { IntOffset( - x = (stepPositions[index] - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), - y = with(density) { (KNOB_SIZE_DP.dp + 4.dp).toPx() }.roundToInt(), + x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + y = 0, ) } - ) + .size(KNOB_SIZE_DP.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { _ -> + // No action needed on drag start + }, + onDragEnd = { + val (closestStep, closestIndex) = findClosestStep(knobPosition.value) + coroutineScope.launch { + knobPosition.animateTo( + targetValue = closestStep, + animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), + ) + } + onValueChange(steps[closestIndex]) + }, + ) { _, dragAmount -> + coroutineScope.launch { + val newPosition = (knobPosition.value + dragAmount.x) + .coerceIn(0f, sliderWidth.toFloat()) + knobPosition.snapTo(newPosition) + } + } + } + ) { + // Outer green circle + Box( + modifier = Modifier + .size(KNOB_SIZE_DP.dp) + .clip(CircleShape) + .background(Colors.Green) + ) { + // Inner white circle + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(Colors.White) + .align(Alignment.Center) + ) + } + } + } + + // Labels participate in layout height (horizontal offset only) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + steps.forEachIndexed { index, step -> + Caption13Up( + text = formatLabel(step), + color = Colors.White64, + textAlign = TextAlign.Center, + modifier = Modifier + .width(KNOB_SIZE_DP.dp) + .offset { + val x = if (index < stepPositions.size) { + ( + stepPositions[index] - + with(density) { KNOB_SIZE_DP.dp.toPx() / 2 } + ).roundToInt() + } else { + 0 + } + IntOffset(x = x, y = 0) + } + ) + } } } } From d90dfb1c53dcd94584d600af91c415eb0e593153 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 17:34:33 +0200 Subject: [PATCH 06/29] fix: layout steplider labels from constraints --- .../java/to/bitkit/ui/components/Slider.kt | 170 +++++++++--------- 1 file changed, 89 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 6b353b306..f1d40fc85 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -21,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -31,10 +33,12 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList @@ -52,6 +56,7 @@ private const val SLIDER_EDGE_INSET_DP = 16 private const val TRACK_HEIGHT_DP = 8 private const val STEP_MARKER_WIDTH_DP = 4 private const val STEP_MARKER_HEIGHT_DP = 16 +private const val LABEL_TOP_PADDING_DP = 4 @Suppress("CyclomaticComplexMethod") @Composable @@ -64,78 +69,69 @@ fun StepSlider( ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - - var sliderWidth by remember { mutableIntStateOf(0) } val knobPosition = remember { Animatable(0f) } + var isDragging by remember { mutableStateOf(false) } - // Calculate step positions (evenly spaced) - val stepPositions = remember(steps, sliderWidth) { - if (sliderWidth == 0) { - emptyList() - } else { - steps.indices.map { index -> + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val sliderWidth = constraints.maxWidth.toFloat() + val stepPositions = remember(steps, sliderWidth) { + if (sliderWidth <= 0f) { + emptyList() + } else { val numSteps = (steps.size - 1).coerceAtLeast(1) - (index.toFloat() / numSteps) * sliderWidth - } - } - } - - // Initialize knob position when value changes - LaunchedEffect(value, stepPositions) { - if (stepPositions.isNotEmpty()) { - val valueIndex = steps.indexOf(value) - if (valueIndex >= 0) { - knobPosition.snapTo(stepPositions[valueIndex]) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } } - } + val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 + val settledX = stepPositions.getOrElse(valueIndex) { 0f } + val knobX = if (isDragging) knobPosition.value else settledX - // Find closest step position - fun findClosestStep(currentPosition: Float): Pair { - if (stepPositions.isEmpty()) return 0f to 0 + fun findClosestStep(currentPosition: Float): Pair { + if (stepPositions.isEmpty()) return 0f to 0 - var closestPosition = stepPositions[0] - var closestIndex = 0 - var minDistance = abs(currentPosition - stepPositions[0]) + var closestPosition = stepPositions[0] + var closestIndex = 0 + var minDistance = abs(currentPosition - stepPositions[0]) - stepPositions.forEachIndexed { index, position -> - val distance = abs(currentPosition - position) - if (distance < minDistance) { - minDistance = distance - closestPosition = position - closestIndex = index + stepPositions.forEachIndexed { index, position -> + val distance = abs(currentPosition - position) + if (distance < minDistance) { + minDistance = distance + closestPosition = position + closestIndex = index + } } - } - return closestPosition to closestIndex - } + return closestPosition to closestIndex + } - Box( - modifier = modifier - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - sliderWidth = coordinates.size.width + LaunchedEffect(settledX, isDragging) { + if (!isDragging) { + knobPosition.snapTo(settledX) } - ) { + } + Column(modifier = Modifier.fillMaxWidth()) { Box( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) ) { - // Track and step markers Canvas( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { + .pointerInput(stepPositions, steps) { detectTapGestures { offset -> val (closestStep, closestIndex) = findClosestStep(offset.x) coroutineScope.launch { + isDragging = true + knobPosition.snapTo(knobX) knobPosition.animateTo( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), ) + isDragging = false } onValueChange(steps[closestIndex]) } @@ -145,7 +141,6 @@ fun StepSlider( val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } val cornerRadius = density.run { 3.dp.toPx() } - // Draw inactive track drawRoundRect( color = Colors.Green32, topLeft = Offset(0f, trackY - trackHeight / 2), @@ -153,18 +148,15 @@ fun StepSlider( cornerRadius = CornerRadius(cornerRadius), ) - // Draw active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { + if (knobX > 0f) { drawRoundRect( color = Colors.Green, topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), + size = Size(knobX, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) } - // Draw step markers val markerWidth = density.run { STEP_MARKER_WIDTH_DP.dp.toPx() } val markerHeight = density.run { STEP_MARKER_HEIGHT_DP.dp.toPx() } val markerRadius = density.run { 2.5.dp.toPx() } @@ -179,20 +171,20 @@ fun StepSlider( } } - // Knob Box( modifier = Modifier .offset { IntOffset( - x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (knobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { + .pointerInput(stepPositions, steps, sliderWidth) { detectDragGestures( - onDragStart = { _ -> - // No action needed on drag start + onDragStart = { + isDragging = true + coroutineScope.launch { knobPosition.snapTo(settledX) } }, onDragEnd = { val (closestStep, closestIndex) = findClosestStep(knobPosition.value) @@ -201,26 +193,25 @@ fun StepSlider( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), ) + isDragging = false } onValueChange(steps[closestIndex]) }, ) { _, dragAmount -> coroutineScope.launch { val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) + .coerceIn(0f, sliderWidth) knobPosition.snapTo(newPosition) } } } ) { - // Outer green circle Box( modifier = Modifier .size(KNOB_SIZE_DP.dp) .clip(CircleShape) .background(Colors.Green) ) { - // Inner white circle Box( modifier = Modifier .size(16.dp) @@ -232,32 +223,49 @@ fun StepSlider( } } - // Labels participate in layout height (horizontal offset only) - Box( + StepSliderLabels( + steps = steps, + formatLabel = formatLabel, modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp) - ) { - steps.forEachIndexed { index, step -> - Caption13Up( - text = formatLabel(step), - color = Colors.White64, - textAlign = TextAlign.Center, - modifier = Modifier - .width(KNOB_SIZE_DP.dp) - .offset { - val x = if (index < stepPositions.size) { - ( - stepPositions[index] - - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 } - ).roundToInt() - } else { - 0 - } - IntOffset(x = x, y = 0) - } - ) - } + .padding(top = LABEL_TOP_PADDING_DP.dp) + ) + } + } +} + +@Composable +private fun StepSliderLabels( + steps: ImmutableList, + formatLabel: (Int) -> String, + modifier: Modifier = Modifier, +) { + Layout( + modifier = modifier, + content = { + steps.forEach { step -> + Caption13Up( + text = formatLabel(step), + color = Colors.White64, + textAlign = TextAlign.Center, + modifier = Modifier.width(KNOB_SIZE_DP.dp) + ) + } + }, + ) { measurables, constraints -> + val placeables = measurables.map { measurable -> + measurable.measure(Constraints()) + } + val height = placeables.maxOfOrNull { it.height } ?: 0 + val width = constraints.maxWidth + val numSteps = (placeables.size - 1).coerceAtLeast(1) + + layout(width, height) { + placeables.forEachIndexed { index, placeable -> + val centerX = (index.toFloat() / numSteps) * width + val x = (centerX - placeable.width / 2f).roundToInt() + .coerceIn(0, (width - placeable.width).coerceAtLeast(0)) + placeable.placeRelative(x, 0) } } } From ef76b51b78ccbeac135dbedcba111df59230d0d3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:33:14 +0200 Subject: [PATCH 07/29] refactor: measure slider as one unit --- .../java/to/bitkit/ui/components/Slider.kt | 97 +++++++++++++------ 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index f1d40fc85..24fb60d48 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -34,7 +33,9 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -60,7 +61,7 @@ private const val LABEL_TOP_PADDING_DP = 4 @Suppress("CyclomaticComplexMethod") @Composable -fun StepSlider( +fun Slider( value: Int, steps: ImmutableList, onValueChange: (Int) -> Unit, @@ -71,20 +72,42 @@ fun StepSlider( val coroutineScope = rememberCoroutineScope() val knobPosition = remember { Animatable(0f) } var isDragging by remember { mutableStateOf(false) } + var layoutWidthPx by remember { mutableIntStateOf(0) } + val knobHeightPx = with(density) { KNOB_SIZE_DP.dp.roundToPx() } + val labelTopPadPx = with(density) { LABEL_TOP_PADDING_DP.dp.roundToPx() } - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val sliderWidth = constraints.maxWidth.toFloat() - val stepPositions = remember(steps, sliderWidth) { - if (sliderWidth <= 0f) { - emptyList() - } else { - val numSteps = (steps.size - 1).coerceAtLeast(1) - steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } - } + val compositionStepPositions = remember(steps, layoutWidthPx) { + val sliderWidth = layoutWidthPx.toFloat() + if (sliderWidth <= 0f) { + emptyList() + } else { + val numSteps = (steps.size - 1).coerceAtLeast(1) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } + } + } + val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 + val settledX = compositionStepPositions.getOrElse(valueIndex) { 0f } + + LaunchedEffect(settledX, isDragging) { + if (!isDragging) { + knobPosition.snapTo(settledX) + } + } + + SubcomposeLayout( + modifier = modifier + .fillMaxWidth() + .onSizeChanged { layoutWidthPx = it.width } + ) { constraints -> + val width = constraints.maxWidth + val sliderWidth = width.toFloat() + val stepPositions = if (sliderWidth <= 0f) { + emptyList() + } else { + val numSteps = (steps.size - 1).coerceAtLeast(1) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } - val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 - val settledX = stepPositions.getOrElse(valueIndex) { 0f } - val knobX = if (isDragging) knobPosition.value else settledX + val knobX = if (isDragging) knobPosition.value else stepPositions.getOrElse(valueIndex) { 0f } fun findClosestStep(currentPosition: Float): Pair { if (stepPositions.isEmpty()) return 0f to 0 @@ -105,13 +128,7 @@ fun StepSlider( return closestPosition to closestIndex } - LaunchedEffect(settledX, isDragging) { - if (!isDragging) { - knobPosition.snapTo(settledX) - } - } - - Column(modifier = Modifier.fillMaxWidth()) { + val trackPlaceable = subcompose(StepSliderSlot.Track) { Box( modifier = Modifier .fillMaxWidth() @@ -222,18 +239,25 @@ fun StepSlider( } } } + }.first().measure(Constraints.fixed(width, knobHeightPx)) + val labelsPlaceable = subcompose(StepSliderSlot.Labels) { StepSliderLabels( steps = steps, formatLabel = formatLabel, - modifier = Modifier - .fillMaxWidth() - .padding(top = LABEL_TOP_PADDING_DP.dp) ) + }.first().measure(Constraints.fixedWidth(width)) + + val height = trackPlaceable.height + labelTopPadPx + labelsPlaceable.height + layout(width, height) { + trackPlaceable.placeRelative(0, 0) + labelsPlaceable.placeRelative(0, trackPlaceable.height + labelTopPadPx) } } } +private enum class StepSliderSlot { Track, Labels } + @Composable private fun StepSliderLabels( steps: ImmutableList, @@ -272,7 +296,7 @@ private fun StepSliderLabels( } /** - * Continuous slider over a [min]..[max] range, styled to match [StepSlider] (same track and + * Continuous slider over a [min]..[max] range, styled to match [Slider] (same track and * knob) but without discrete steps. Used to pick a transfer amount within its allowed limits. */ @Composable @@ -393,7 +417,7 @@ private fun Preview() { AppThemeSurface { var value by remember { mutableIntStateOf(10) } Column(modifier = Modifier.padding(32.dp)) { - StepSlider( + Slider( value = value, steps = persistentListOf(1, 5, 10, 20, 50), onValueChange = { value = it }, @@ -404,7 +428,7 @@ private fun Preview() { @Preview @Composable -private fun AmountSliderPreview() { +private fun PreviewUnitStops() { AppThemeSurface { var value by remember { mutableLongStateOf(72_000L) } Column(modifier = Modifier.padding(32.dp)) { @@ -420,13 +444,22 @@ private fun AmountSliderPreview() { @Preview @Composable -private fun Preview2() { +private fun PreviewVerticalStack() { AppThemeSurface { + var dollars by remember { mutableIntStateOf(1) } + var times by remember { mutableIntStateOf(1) } Column(modifier = Modifier.padding(32.dp)) { - StepSlider( - value = 5, - steps = persistentListOf(1, 2, 5, 10), - onValueChange = {}, + Slider( + value = dollars, + steps = persistentListOf(1, 5, 10, 20, 50), + onValueChange = { dollars = it }, + ) + VerticalSpacer(32.dp) + Slider( + value = 50, + steps = persistentListOf(1, 3, 5, 10, 50), + onValueChange = { times = it }, + formatLabel = { "${it}×" }, ) } } From af4163d1fa9934ddd0dce9ea632e83297cf1231c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:33:24 +0200 Subject: [PATCH 08/29] fix: spacing and copy --- .../bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 8 ++++---- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 23992e2fe..af3495200 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -22,7 +22,7 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.StepSlider +import to.bitkit.ui.components.Slider import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -101,7 +101,7 @@ fun QuickPaySettingsScreenContent( Spacer(modifier = Modifier.height(16.dp)) - StepSlider( + Slider( value = quickPayAmount, steps = sliderSteps, onValueChange = onQuickPayAmountChange, @@ -115,7 +115,7 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) BodyM( text = stringResource(R.string.settings__quickpay__settings__daily_text) @@ -126,7 +126,7 @@ fun QuickPaySettingsScreenContent( Spacer(modifier = Modifier.height(16.dp)) - StepSlider( + Slider( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cf21d8f5..85b43c59c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -934,7 +934,7 @@ <accent>Frictionless</accent>\npayments QuickPay Daily QuickPay limit - Auto-pay up to ${limit} per day without confirmation or PIN ({multiplier}× your threshold). After that, payments open Confirm. + Auto-pay up to ${limit} per day without PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold * Bitkit QuickPay exclusively supports payments from your Spending Balance. If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. From 17f544ca8a6f205b0e76fb0c5dc8f7ba71daf97e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:58:30 +0200 Subject: [PATCH 09/29] fix: snap slider tap from current value --- .../java/to/bitkit/ui/components/Slider.kt | 10 +++++--- .../quickPay/QuickPaySettingsScreen.kt | 23 ++++++++++--------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 24fb60d48..607774d51 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -87,6 +88,7 @@ fun Slider( } val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 val settledX = compositionStepPositions.getOrElse(valueIndex) { 0f } + val settledXState = rememberUpdatedState(settledX) LaunchedEffect(settledX, isDragging) { if (!isDragging) { @@ -142,8 +144,8 @@ fun Slider( detectTapGestures { offset -> val (closestStep, closestIndex) = findClosestStep(offset.x) coroutineScope.launch { + knobPosition.snapTo(settledXState.value) isDragging = true - knobPosition.snapTo(knobX) knobPosition.animateTo( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), @@ -200,8 +202,10 @@ fun Slider( .pointerInput(stepPositions, steps, sliderWidth) { detectDragGestures( onDragStart = { - isDragging = true - coroutineScope.launch { knobPosition.snapTo(settledX) } + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = true + } }, onDragEnd = { val (closestStep, closestIndex) = findClosestStep(knobPosition.value) diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index af3495200..53a4d4247 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -2,7 +2,6 @@ package to.bitkit.ui.settings.quickPay import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -22,7 +21,9 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.Slider +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -75,7 +76,7 @@ fun QuickPaySettingsScreenContent( Column( modifier = Modifier.padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) SettingsSwitchRow( title = stringResource(R.string.settings__quickpay__settings__toggle), @@ -84,7 +85,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayToggle") ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.settings__quickpay__settings__text) @@ -92,14 +93,14 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(32.dp)) + VerticalSpacer(32.dp) Caption13Up( text = stringResource(R.string.settings__quickpay__settings__label), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Slider( value = quickPayAmount, @@ -108,14 +109,14 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayAmountSlider") ) - Spacer(modifier = Modifier.height(32.dp)) + VerticalSpacer(32.dp) Caption13Up( text = stringResource(R.string.settings__quickpay__settings__daily_label), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.settings__quickpay__settings__daily_text) @@ -124,7 +125,7 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Slider( value = quickPayDailyLimitMultiplier, @@ -134,7 +135,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() Image( painter = painterResource(R.drawable.fast_forward), contentDescription = null, @@ -142,14 +143,14 @@ fun QuickPaySettingsScreenContent( .fillMaxWidth() .height(256.dp) ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() BodyS( text = stringResource(R.string.settings__quickpay__settings__note), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } } From 7385c7355556c90a7ccee1712a6ca7552b9e5e8c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 19:59:08 +0200 Subject: [PATCH 10/29] fix: simplify multiplier string templates --- app/src/main/java/to/bitkit/ui/components/Slider.kt | 2 +- .../to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 607774d51..0fe0bedb9 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -463,7 +463,7 @@ private fun PreviewVerticalStack() { value = 50, steps = persistentListOf(1, 3, 5, 10, 50), onValueChange = { times = it }, - formatLabel = { "${it}×" }, + formatLabel = { "$it×" }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 53a4d4247..a309c85e6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -131,7 +131,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, - formatLabel = { "${it}×" }, + formatLabel = { "$it×" }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) From af959c93662aafa6936534bec8440b644135ae4c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 11:24:21 +0200 Subject: [PATCH 11/29] fix: harden quickpay daily spend limits --- .../quickPay/QuickPaySettingsScreenTest.kt | 5 +- .../main/java/to/bitkit/data/CacheStore.kt | 22 +++ .../java/to/bitkit/ui/components/Slider.kt | 36 +++++ .../quickPay/QuickPaySettingsScreen.kt | 11 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 33 ++-- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 151 +++++++++++------- .../java/to/bitkit/data/CacheStoreTest.kt | 15 ++ 7 files changed, 202 insertions(+), 71 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt index 1a179f2b0..cb3457e75 100644 --- a/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt @@ -34,12 +34,14 @@ class QuickPaySettingsScreenTest { QuickPaySettingsScreenContent( isQuickPayEnabled = true, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, ) } } composeTestRule.onNodeWithTag("QuickpayToggle").assertIsDisplayed() - composeTestRule.onNodeWithTag("quickpay_amount_slider").assertIsDisplayed() + composeTestRule.onNodeWithTag("QuickpayAmountSlider").assertIsDisplayed() + composeTestRule.onNodeWithTag("QuickpayDailyLimitSlider").assertIsDisplayed() } @Test @@ -52,6 +54,7 @@ class QuickPaySettingsScreenTest { QuickPaySettingsScreenContent( isQuickPayEnabled = false, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, onToggleQuickPay = { enabled -> toggleCalled = true toggleValue = enabled diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 0a8fe8e5d..e6645c207 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -144,6 +144,28 @@ class CacheStore @Inject constructor( return if (data.quickPaySpendDayKey == dayKey) data.quickPaySpentUsdToday else 0.0 } + suspend fun tryReserveQuickPaySpendUsd(amountUsd: Double, dayKey: String, dailyCapUsd: Double): Boolean { + var reserved = false + store.updateData { + val spent = if (it.quickPaySpendDayKey == dayKey) it.quickPaySpentUsdToday else 0.0 + if (spent + amountUsd > dailyCapUsd) return@updateData it + reserved = true + if (it.quickPaySpendDayKey != dayKey) { + it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) + } else { + it.copy(quickPaySpentUsdToday = spent + amountUsd) + } + } + return reserved + } + + suspend fun releaseQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + store.updateData { + if (it.quickPaySpendDayKey != dayKey) return@updateData it + it.copy(quickPaySpentUsdToday = (it.quickPaySpentUsdToday - amountUsd).coerceAtLeast(0.0)) + } + } + suspend fun recordQuickPaySpendUsd(amountUsd: Double, dayKey: String) { store.updateData { if (it.quickPaySpendDayKey != dayKey) { diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 0fe0bedb9..851ee7312 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -38,6 +38,11 @@ import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints @@ -100,6 +105,12 @@ fun Slider( modifier = modifier .fillMaxWidth() .onSizeChanged { layoutWidthPx = it.width } + .stepSliderSemantics( + valueIndex = valueIndex, + stepCount = steps.size, + stateDescription = formatLabel(value), + onIndexChange = { onValueChange(steps[it]) }, + ) ) { constraints -> val width = constraints.maxWidth val sliderWidth = width.toFloat() @@ -218,6 +229,12 @@ fun Slider( } onValueChange(steps[closestIndex]) }, + onDragCancel = { + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = false + } + }, ) { _, dragAmount -> coroutineScope.launch { val newPosition = (knobPosition.value + dragAmount.x) @@ -262,6 +279,25 @@ fun Slider( private enum class StepSliderSlot { Track, Labels } +private fun Modifier.stepSliderSemantics( + valueIndex: Int, + stepCount: Int, + stateDescription: String, + onIndexChange: (Int) -> Unit, +): Modifier = semantics { + this.stateDescription = stateDescription + val lastIndex = (stepCount - 1).coerceAtLeast(0) + progressBarRangeInfo = ProgressBarRangeInfo( + current = valueIndex.toFloat(), + range = 0f..lastIndex.toFloat(), + steps = (stepCount - 2).coerceAtLeast(0), + ) + setProgress { target -> + onIndexChange(target.roundToInt().coerceIn(0, lastIndex)) + true + } +} + @Composable private fun StepSliderLabels( steps: ImmutableList, diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index a309c85e6..0e7f94ca7 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -21,7 +23,6 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.Slider import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow @@ -74,7 +75,9 @@ fun QuickPaySettingsScreenContent( ) Column( - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) ) { VerticalSpacer(16.dp) @@ -135,7 +138,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) - FillHeight() + VerticalSpacer(32.dp) Image( painter = painterResource(R.drawable.fast_forward), contentDescription = null, @@ -143,7 +146,7 @@ fun QuickPaySettingsScreenContent( .fillMaxWidth() .height(256.dp) ) - FillHeight() + VerticalSpacer(32.dp) BodyS( text = stringResource(R.string.settings__quickpay__settings__note), diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fb54acf8b..beab88153 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,6 +74,7 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -2633,21 +2634,31 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + return isWithinQuickPayThreshold(amountSats, settings) && isWithinQuickPayDailyCap(amountSats, settings) + } + + private suspend fun isWithinQuickPayThreshold(amountSats: ULong, settings: SettingsData): Boolean { val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() - if (quickPayAmountSats == null || amountUsd == null || amountSats > quickPayAmountSats) return false + ?: return false + return amountSats <= quickPayAmountSats + } - val dailyCapUsd = settings.quickPayAmount.toDouble() * settings.quickPayDailyLimitMultiplier + private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + ?: return false + val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() + val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return false + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return false val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) - if (spentUsdToday + amountUsd > dailyCapUsd) { - Logger.info( - "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", - context = TAG, - ) - return false - } + if (spentUsdToday + amountUsd <= dailyCapUsd) return true - return true + Logger.info( + "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + context = TAG, + ) + return false } private fun resetAmountInput() { diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index e4fb98fa0..136bc941b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -7,11 +7,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.quickPaySpendDayKey @@ -32,6 +34,7 @@ class QuickPayViewModel @Inject constructor( private val pendingPaymentRepo: PendingPaymentRepo, private val currencyRepo: CurrencyRepo, private val cacheStore: CacheStore, + private val settingsStore: SettingsStore, ) : ViewModel() { companion object { @@ -45,68 +48,99 @@ class QuickPayViewModel @Inject constructor( fun pay(data: QuickPayData) { viewModelScope.launch { - val (bolt11, amount, displaySats) = when (data) { - is QuickPayData.Bolt11 -> { - Logger.info("QuickPay: processing bolt11 invoice") - Triple(data.bolt11, null, data.sats) - } + val prepared = preparePayment(data) ?: return@launch + val dayKey = quickPaySpendDayKey() + if (!reserveSpend(prepared.amountUsd, dayKey)) return@launch - is QuickPayData.LnurlPay -> { - Logger.info("QuickPay: fetching LNURL Pay invoice from callback") - val invoice = lightningRepo.fetchLnurlInvoice( - data = data.data, - amountMsats = data.data.callbackAmountMsats(data.sats), - ) - .getOrElse { error -> - _uiState.update { - it.copy(result = QuickPayResult.Error(error.message.orEmpty())) - } - return@launch - } - Triple(invoice.bolt11, null, data.sats) - } + sendLightning(prepared.bolt11, prepared.amount) + .onSuccess { onPaymentSuccess(it, prepared.displaySats) } + .onFailure { onPaymentFailure(it, prepared.displaySats, prepared.amountUsd, dayKey) } + } + } + + private suspend fun preparePayment(data: QuickPayData): PreparedQuickPay? { + val (bolt11, amount, displaySats) = when (data) { + is QuickPayData.Bolt11 -> { + Logger.info("QuickPay: processing bolt11 invoice") + Triple(data.bolt11, null, data.sats) } - sendLightning(bolt11, amount) - .onSuccess { paymentHash -> - Logger.info("QuickPay lightning payment successful") - recordQuickPaySpend(displaySats) - _uiState.update { - it.copy( - result = QuickPayResult.Success( - paymentHash = paymentHash, - amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE - ) - ) - } - }.onFailure { error -> - if (error is PaymentPendingException) { - Logger.info("QuickPay lightning payment pending", context = TAG) - pendingPaymentRepo.track(error.paymentHash) - recordQuickPaySpend(displaySats) - _uiState.update { - it.copy( - result = QuickPayResult.Pending( - paymentHash = error.paymentHash, - amount = displaySats.toLong(), - ) - ) - } - return@onFailure - } - Logger.error("QuickPay lightning payment failed", error, context = TAG) - - _uiState.update { - it.copy(result = QuickPayResult.Error(error.message.orEmpty())) - } + is QuickPayData.LnurlPay -> { + Logger.info("QuickPay: fetching LNURL Pay invoice from callback") + val invoice = lightningRepo.fetchLnurlInvoice( + data = data.data, + amountMsats = data.data.callbackAmountMsats(data.sats), + ).getOrElse { error -> + _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + return null } + Triple(invoice.bolt11, null, data.sats) + } + } + val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() + if (amountUsd == null) { + _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + return null + } + return PreparedQuickPay(bolt11, amount, displaySats, amountUsd) + } + + private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { + val dailyCapUsd = resolveDailyCapUsd() + if (dailyCapUsd == null) { + _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + return false + } + val reserved = cacheStore.tryReserveQuickPaySpendUsd(amountUsd, dayKey, dailyCapUsd) + if (!reserved) { + Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountUsd'", context = TAG) + _uiState.update { it.copy(result = QuickPayResult.Error("Daily QuickPay limit reached")) } } + return reserved } - private suspend fun recordQuickPaySpend(amountSats: ULong) { - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() - ?: return - cacheStore.recordQuickPaySpendUsd(amountUsd, quickPaySpendDayKey()) + private fun onPaymentSuccess(paymentHash: String, displaySats: ULong) { + Logger.info("QuickPay lightning payment successful") + _uiState.update { + it.copy( + result = QuickPayResult.Success( + paymentHash = paymentHash, + amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE + ) + ) + } + } + + private suspend fun onPaymentFailure( + error: Throwable, + displaySats: ULong, + amountUsd: Double, + dayKey: String, + ) { + if (error is PaymentPendingException) { + Logger.info("QuickPay lightning payment pending", context = TAG) + pendingPaymentRepo.track(error.paymentHash) + _uiState.update { + it.copy( + result = QuickPayResult.Pending( + paymentHash = error.paymentHash, + amount = displaySats.toLong(), + ) + ) + } + return + } + Logger.error("QuickPay lightning payment failed", error, context = TAG) + cacheStore.releaseQuickPaySpendUsd(amountUsd, dayKey) + _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + } + + private suspend fun resolveDailyCapUsd(): Double? { + val settings = settingsStore.data.first() + val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + ?: return null + val dailyCapSats = thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() + return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() } private suspend fun sendLightning( @@ -134,6 +168,13 @@ class QuickPayViewModel @Inject constructor( } } +private data class PreparedQuickPay( + val bolt11: String, + val amount: ULong?, + val displaySats: ULong, + val amountUsd: Double, +) + sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 90855eb81..c595d7f47 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -116,4 +116,19 @@ class CacheStoreTest : BaseUnitTest() { assertEquals(4.0, sut.quickPaySpentUsdForDay("2026-08-16")) assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) } + + @Test + fun `tryReserveQuickPaySpendUsd reserves under the cap and rejects over it`() = test { + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertFalse(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertEquals(20.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `releaseQuickPaySpendUsd rolls back a reservation`() = test { + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + sut.releaseQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15") + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } } From 7cdae86a21edc7ea3bf2ca636d1bb32689be963d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 12/29] fix: localize error messages and texts --- .../quickPay/QuickPaySettingsScreen.kt | 3 ++- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 25 +++++++++++++++---- app/src/main/res/values/strings.xml | 3 +++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 0e7f94ca7..ea30a78d9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -66,6 +66,7 @@ fun QuickPaySettingsScreenContent( val sliderSteps = remember { persistentListOf(1, 5, 10, 20, 50) } val dailyLimitSteps = remember { persistentListOf(1, 3, 5, 10, 50) } val dailyLimitUsd = quickPayAmount * quickPayDailyLimitMultiplier + val multiplierFormat = stringResource(R.string.settings__quickpay__settings__multiplier_format) ScreenColumn { AppTopBar( @@ -134,7 +135,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, - formatLabel = { "$it×" }, + formatLabel = { multiplierFormat.replace("{multiplier}", it.toString()) }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 136bc941b..ae9e05a07 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult @@ -71,7 +72,7 @@ class QuickPayViewModel @Inject constructor( data = data.data, amountMsats = data.data.callbackAmountMsats(data.sats), ).getOrElse { error -> - _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + setError(error) return null } Triple(invoice.bolt11, null, data.sats) @@ -79,7 +80,7 @@ class QuickPayViewModel @Inject constructor( } val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() if (amountUsd == null) { - _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + setError(QuickPayCurrencyConversionError()) return null } return PreparedQuickPay(bolt11, amount, displaySats, amountUsd) @@ -88,13 +89,13 @@ class QuickPayViewModel @Inject constructor( private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { val dailyCapUsd = resolveDailyCapUsd() if (dailyCapUsd == null) { - _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + setError(QuickPayCurrencyConversionError()) return false } val reserved = cacheStore.tryReserveQuickPaySpendUsd(amountUsd, dayKey, dailyCapUsd) if (!reserved) { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountUsd'", context = TAG) - _uiState.update { it.copy(result = QuickPayResult.Error("Daily QuickPay limit reached")) } + setError(QuickPayDailyLimitReachedError()) } return reserved } @@ -132,7 +133,17 @@ class QuickPayViewModel @Inject constructor( } Logger.error("QuickPay lightning payment failed", error, context = TAG) cacheStore.releaseQuickPaySpendUsd(amountUsd, dayKey) - _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + setError(error) + } + + private fun setError(error: Throwable) { + _uiState.update { it.copy(result = QuickPayResult.Error(errorMessage(error))) } + } + + private fun errorMessage(error: Throwable): String = when (error) { + is QuickPayCurrencyConversionError -> context.getString(R.string.wallet__send_quickpay__currency_conversion) + is QuickPayDailyLimitReachedError -> context.getString(R.string.wallet__send_quickpay__daily_limit) + else -> error.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.common__error_body) } private suspend fun resolveDailyCapUsd(): Double? { @@ -175,6 +186,10 @@ private data class PreparedQuickPay( val amountUsd: Double, ) +private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") + +private class QuickPayDailyLimitReachedError : AppError("Daily QuickPay limit reached") + sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85b43c59c..a93e46970 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -936,6 +936,7 @@ Daily QuickPay limit Auto-pay up to ${limit} per day without PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold + {multiplier}× * Bitkit QuickPay exclusively supports payments from your Spending Balance. If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. Enable QuickPay @@ -1239,6 +1240,8 @@ Reserve Balance This payment is taking a bit longer than expected. You can continue using Bitkit. Payment Pending + Currency conversion failed + Daily QuickPay limit reached QuickPay Paying\n<accent>invoice...</accent> Confirm From 3593678a85f384c7603eef8ed93e62094b8b061e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 13/29] refactor: use USD constant --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 13 +++++++------ .../java/to/bitkit/viewmodels/QuickPayViewModel.kt | 7 ++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index beab88153..653c73fcd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -124,6 +124,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.TransportType +import to.bitkit.models.USD import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.sanitizedDeeplinkLogValue @@ -2638,18 +2639,18 @@ class AppViewModel @Inject constructor( } private suspend fun isWithinQuickPayThreshold(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return false return amountSats <= quickPayAmountSats } private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return false val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() - val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() ?: return false - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull()?.value?.toDouble() ?: return false val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) if (spentUsdToday + amountUsd <= dailyCapUsd) return true @@ -2702,7 +2703,7 @@ class AppViewModel @Inject constructor( return } - val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull() ?: return + val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return if ( amountInUsd.value > BigDecimal(SEND_AMOUNT_WARNING_THRESHOLD) && settings.enableSendAmountWarning && @@ -2735,7 +2736,7 @@ class AppViewModel @Inject constructor( return } - val feeInUsd = currencyRepo.convertSatsToFiat(totalFee.toLong(), "USD").getOrNull() ?: return + val feeInUsd = currencyRepo.convertSatsToFiat(totalFee.toLong(), USD).getOrNull() ?: return if ( feeInUsd.value > BigDecimal(TEN_USD) && SanityWarning.FEE_OVER_10_USD !in _sendUiState.value.confirmedWarnings diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index ae9e05a07..7ee91cd50 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -20,6 +20,7 @@ import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil +import to.bitkit.models.USD import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException @@ -78,7 +79,7 @@ class QuickPayViewModel @Inject constructor( Triple(invoice.bolt11, null, data.sats) } } - val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() + val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), USD).getOrNull()?.value?.toDouble() if (amountUsd == null) { setError(QuickPayCurrencyConversionError()) return null @@ -148,10 +149,10 @@ class QuickPayViewModel @Inject constructor( private suspend fun resolveDailyCapUsd(): Double? { val settings = settingsStore.data.first() - val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return null val dailyCapSats = thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() - return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() } private suspend fun sendLightning( From 4761c75428fed94aec570aac2e6028ab452fc6ef Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 14/29] feat: use quickpay routing fee on ui --- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 7ee91cd50..3fb41e0ae 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -21,6 +21,8 @@ import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil import to.bitkit.models.USD +import to.bitkit.models.msatFloorOf +import to.bitkit.models.safe import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException @@ -55,7 +57,7 @@ class QuickPayViewModel @Inject constructor( if (!reserveSpend(prepared.amountUsd, dayKey)) return@launch sendLightning(prepared.bolt11, prepared.amount) - .onSuccess { onPaymentSuccess(it, prepared.displaySats) } + .onSuccess { onPaymentSuccess(it.paymentHash, prepared.displaySats, it.feePaidSats) } .onFailure { onPaymentFailure(it, prepared.displaySats, prepared.amountUsd, dayKey) } } } @@ -101,13 +103,13 @@ class QuickPayViewModel @Inject constructor( return reserved } - private fun onPaymentSuccess(paymentHash: String, displaySats: ULong) { + private fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { Logger.info("QuickPay lightning payment successful") _uiState.update { it.copy( result = QuickPayResult.Success( paymentHash = paymentHash, - amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE + amountWithFee = (displaySats.safe() + feePaidSats.safe()).toLong(), ) ) } @@ -158,7 +160,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun sendLightning( bolt11: String, amount: ULong? = null, - ): Result { + ): Result { val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount) .onFailure { exception -> return Result.failure(exception) @@ -168,7 +170,15 @@ class QuickPayViewModel @Inject constructor( // Wait until matching payment event is received (with timeout for hold invoices) val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { when (it) { - is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) + is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete( + Result.success( + SettledQuickPayPayment( + paymentHash = hash, + feePaidSats = msatFloorOf(it.feePaidMsat ?: 0u), + ) + ) + ) + is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( Result.failure(AppError(it.reason.toUserMessage(context))) ) @@ -187,6 +197,11 @@ private data class PreparedQuickPay( val amountUsd: Double, ) +private data class SettledQuickPayPayment( + val paymentHash: PaymentId, + val feePaidSats: ULong, +) + private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") private class QuickPayDailyLimitReachedError : AppError("Daily QuickPay limit reached") From 07a88a2b742c26749f858e7e96f7a0af44c381a3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 20:42:42 +0200 Subject: [PATCH 15/29] fix: harden quickpay spend and slider --- .../main/java/to/bitkit/data/CacheStore.kt | 90 ++++++++++++++----- .../bitkit/repositories/PendingPaymentRepo.kt | 6 +- .../java/to/bitkit/ui/components/Slider.kt | 73 ++++++++++----- .../screens/wallets/send/SendPendingScreen.kt | 7 +- .../java/to/bitkit/ui/sheets/SendSheet.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 25 ++++-- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 39 ++++---- .../java/to/bitkit/data/CacheStoreTest.kt | 76 +++++++++++----- .../ui/components/StepSliderMappingTest.kt | 25 ++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 14 ++- 10 files changed, 256 insertions(+), 103 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index e6645c207..e97ade4ba 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,40 +139,72 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } - suspend fun quickPaySpentUsdForDay(dayKey: String): Double { + suspend fun quickPaySpentSatsForDay(dayKey: String): Long { val data = store.data.first() - return if (data.quickPaySpendDayKey == dayKey) data.quickPaySpentUsdToday else 0.0 + return data.quickPaySpendFor(dayKey).spentSats } - suspend fun tryReserveQuickPaySpendUsd(amountUsd: Double, dayKey: String, dailyCapUsd: Double): Boolean { + suspend fun tryReserveQuickPaySpendSats(amountSats: Long, dayKey: String, dailyCapSats: Long): Boolean { var reserved = false store.updateData { - val spent = if (it.quickPaySpendDayKey == dayKey) it.quickPaySpentUsdToday else 0.0 - if (spent + amountUsd > dailyCapUsd) return@updateData it + val spend = it.quickPaySpendFor(dayKey) + if (spend.spentSats + amountSats > dailyCapSats) return@updateData it reserved = true - if (it.quickPaySpendDayKey != dayKey) { - it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) - } else { - it.copy(quickPaySpentUsdToday = spent + amountUsd) - } + it.copy( + quickPaySpendDayKey = spend.dayKey, + quickPaySpentSatsToday = spend.spentSats + amountSats, + ) } return reserved } - suspend fun releaseQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + suspend fun releaseQuickPaySpendSats(amountSats: Long, dayKey: String) { store.updateData { - if (it.quickPaySpendDayKey != dayKey) return@updateData it - it.copy(quickPaySpentUsdToday = (it.quickPaySpentUsdToday - amountUsd).coerceAtLeast(0.0)) + val spend = it.quickPaySpendFor(dayKey) + if (spend.dayKey != it.quickPaySpendDayKey) return@updateData it + it.copy(quickPaySpentSatsToday = (spend.spentSats - amountSats).coerceAtLeast(0L)) } } - suspend fun recordQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + suspend fun recordQuickPaySpendSats(amountSats: Long, dayKey: String) { store.updateData { - if (it.quickPaySpendDayKey != dayKey) { - it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) - } else { - it.copy(quickPaySpentUsdToday = it.quickPaySpentUsdToday + amountUsd) - } + val spend = it.quickPaySpendFor(dayKey) + it.copy( + quickPaySpendDayKey = spend.dayKey, + quickPaySpentSatsToday = spend.spentSats + amountSats, + ) + } + } + + suspend fun rememberQuickPayReservation(paymentHash: String, amountSats: Long, dayKey: String) { + if (paymentHash.isBlank()) return + store.updateData { + it.copy( + quickPayReservations = it.quickPayReservations + ( + paymentHash to QuickPaySpendReservation(amountSats = amountSats, dayKey = dayKey) + ), + ) + } + } + + suspend fun releaseQuickPayReservation(paymentHash: String) { + if (paymentHash.isBlank()) return + store.updateData { data -> + val reservation = data.quickPayReservations[paymentHash] ?: return@updateData data + val spend = data.quickPaySpendFor(reservation.dayKey) + data.copy( + quickPaySpendDayKey = spend.dayKey, + quickPaySpentSatsToday = (spend.spentSats - reservation.amountSats).coerceAtLeast(0L), + quickPayReservations = data.quickPayReservations - paymentHash, + ) + } + } + + suspend fun clearQuickPayReservation(paymentHash: String) { + if (paymentHash.isBlank()) return + store.updateData { + if (paymentHash !in it.quickPayReservations) return@updateData it + it.copy(quickPayReservations = it.quickPayReservations - paymentHash) } } @@ -202,7 +234,8 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), val quickPaySpendDayKey: String = "", - val quickPaySpentUsdToday: Double = 0.0, + val quickPaySpentSatsToday: Long = 0L, + val quickPayReservations: Map = emptyMap(), ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || @@ -213,4 +246,21 @@ data class AppCacheData( fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "") fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") + + fun quickPaySpendFor(dayKey: String): QuickPayDaySpend = when { + quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) + dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentSatsToday) + else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentSatsToday) + } } + +@Serializable +data class QuickPaySpendReservation( + val amountSats: Long, + val dayKey: String, +) + +data class QuickPayDaySpend( + val dayKey: String, + val spentSats: Long, +) diff --git a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt index 007c7b2f7..585e74885 100644 --- a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt @@ -47,7 +47,11 @@ class PaymentPendingException(val paymentHash: String) : AppError("Payment pendi sealed interface PendingPaymentResolution { val paymentHash: String - data class Success(override val paymentHash: String) : PendingPaymentResolution + data class Success( + override val paymentHash: String, + val amountWithFeeSats: Long? = null, + ) : PendingPaymentResolution + data class Failure(override val paymentHash: String) : PendingPaymentResolution } diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 851ee7312..33f797d9b 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -4,7 +4,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.SpringSpec import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,6 +38,7 @@ import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics @@ -47,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -58,6 +60,15 @@ import kotlin.math.roundToInt private const val KNOB_SIZE_DP = 32 +internal fun sliderPointerToLogical(x: Float, width: Float, isRtl: Boolean): Float = + if (isRtl) width - x else x + +internal fun sliderLogicalToVisual(x: Float, width: Float, isRtl: Boolean): Float = + if (isRtl) width - x else x + +internal fun sliderDragDeltaToLogical(deltaX: Float, isRtl: Boolean): Float = + if (isRtl) -deltaX else deltaX + /** Horizontal inset so the knob stays clear of the screen edge and its system back-gesture zone. */ private const val SLIDER_EDGE_INSET_DP = 16 private const val TRACK_HEIGHT_DP = 8 @@ -75,6 +86,7 @@ fun Slider( formatLabel: (Int) -> String = { "$$it" }, ) { val density = LocalDensity.current + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val coroutineScope = rememberCoroutineScope() val knobPosition = remember { Animatable(0f) } var isDragging by remember { mutableStateOf(false) } @@ -121,6 +133,7 @@ fun Slider( steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } val knobX = if (isDragging) knobPosition.value else stepPositions.getOrElse(valueIndex) { 0f } + val visualKnobX = sliderLogicalToVisual(knobX, sliderWidth, isRtl) fun findClosestStep(currentPosition: Float): Pair { if (stepPositions.isEmpty()) return 0f to 0 @@ -151,9 +164,10 @@ fun Slider( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(stepPositions, steps) { + .pointerInput(stepPositions, steps, isRtl, sliderWidth) { detectTapGestures { offset -> - val (closestStep, closestIndex) = findClosestStep(offset.x) + val logicalX = sliderPointerToLogical(offset.x, sliderWidth, isRtl) + val (closestStep, closestIndex) = findClosestStep(logicalX) coroutineScope.launch { knobPosition.snapTo(settledXState.value) isDragging = true @@ -179,10 +193,12 @@ fun Slider( ) if (knobX > 0f) { + val activeLeft = if (isRtl) visualKnobX else 0f + val activeWidth = if (isRtl) size.width - visualKnobX else visualKnobX drawRoundRect( color = Colors.Green, - topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(knobX, trackHeight), + topLeft = Offset(activeLeft, trackY - trackHeight / 2), + size = Size(activeWidth, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) } @@ -192,9 +208,10 @@ fun Slider( val markerRadius = density.run { 2.5.dp.toPx() } stepPositions.forEach { position -> + val visualX = sliderLogicalToVisual(position, size.width, isRtl) drawRoundRect( color = Colors.White, - topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), + topLeft = Offset(visualX - markerWidth / 2, trackY - markerHeight / 2), size = Size(markerWidth, markerHeight), cornerRadius = CornerRadius(markerRadius), ) @@ -205,13 +222,13 @@ fun Slider( modifier = Modifier .offset { IntOffset( - x = (knobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (visualKnobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(stepPositions, steps, sliderWidth) { - detectDragGestures( + .pointerInput(stepPositions, steps, sliderWidth, isRtl) { + detectHorizontalDragGestures( onDragStart = { coroutineScope.launch { knobPosition.snapTo(settledXState.value) @@ -237,8 +254,9 @@ fun Slider( }, ) { _, dragAmount -> coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth) + val newPosition = ( + knobPosition.value + sliderDragDeltaToLogical(dragAmount, isRtl) + ).coerceIn(0f, sliderWidth) knobPosition.snapTo(newPosition) } } @@ -348,6 +366,7 @@ fun AmountSlider( modifier: Modifier = Modifier, ) { val density = LocalDensity.current + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val coroutineScope = rememberCoroutineScope() var sliderWidth by remember { mutableIntStateOf(0) } @@ -356,9 +375,9 @@ fun AmountSlider( fun fractionFor(v: Long): Float = ((v - min).toFloat() / span).coerceIn(0f, 1f) - fun valueFor(positionPx: Float): Long { + fun valueFor(logicalPositionPx: Float): Long { if (sliderWidth == 0) return min - val fraction = (positionPx / sliderWidth).coerceIn(0f, 1f) + val fraction = (logicalPositionPx / sliderWidth).coerceIn(0f, 1f) return (min + (fraction * span).roundToInt()).coerceIn(min, max) } @@ -369,6 +388,9 @@ fun AmountSlider( } } + val widthPx = sliderWidth.toFloat() + val visualKnobX = sliderLogicalToVisual(knobPosition.value, widthPx, isRtl) + Box( modifier = modifier .fillMaxWidth() @@ -381,10 +403,11 @@ fun AmountSlider( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(sliderWidth, min, max) { + .pointerInput(sliderWidth, min, max, isRtl) { detectTapGestures { offset -> - val v = valueFor(offset.x) - coroutineScope.launch { knobPosition.snapTo(fractionFor(v) * sliderWidth) } + val logicalX = sliderPointerToLogical(offset.x, widthPx, isRtl) + val v = valueFor(logicalX) + coroutineScope.launch { knobPosition.snapTo(fractionFor(v) * widthPx) } onValueChange(v) } } @@ -401,11 +424,12 @@ fun AmountSlider( cornerRadius = CornerRadius(cornerRadius), ) // Active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { + if (knobPosition.value > 0) { + val activeLeft = if (isRtl) visualKnobX else 0f + val activeWidth = if (isRtl) size.width - visualKnobX else visualKnobX drawRoundRect( color = Colors.Green, - topLeft = Offset(0f, trackY - trackHeight / 2), + topLeft = Offset(activeLeft, trackY - trackHeight / 2), size = Size(activeWidth, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) @@ -417,16 +441,17 @@ fun AmountSlider( modifier = Modifier .offset { IntOffset( - x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (visualKnobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(sliderWidth, min, max) { - detectDragGestures { _, dragAmount -> + .pointerInput(sliderWidth, min, max, isRtl) { + detectHorizontalDragGestures { _, dragAmount -> coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) + val newPosition = ( + knobPosition.value + sliderDragDeltaToLogical(dragAmount, isRtl) + ).coerceIn(0f, widthPx) knobPosition.snapTo(newPosition) onValueChange(valueFor(newPosition)) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index ead96da93..ae02bb37a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt @@ -45,7 +45,7 @@ import to.bitkit.ui.theme.Colors fun SendPendingScreen( paymentHash: String, amount: Long, - onPaymentSuccess: (String) -> Unit, + onPaymentSuccess: (String, Long) -> Unit, onPaymentError: () -> Unit, onClose: () -> Unit, onViewDetails: (String) -> Unit, @@ -58,7 +58,10 @@ fun SendPendingScreen( uiState.resolution?.let { resolution -> LaunchedEffect(resolution) { when (resolution) { - is PendingPaymentResolution.Success -> onPaymentSuccess(resolution.paymentHash) + is PendingPaymentResolution.Success -> onPaymentSuccess( + resolution.paymentHash, + resolution.amountWithFeeSats ?: amount, + ) is PendingPaymentResolution.Failure -> onPaymentError() } viewModel.onResolutionHandled() diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 5b801ee8a..0cff6a9f3 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -335,13 +335,13 @@ fun SendSheet( SendPendingScreen( paymentHash = route.paymentHash, amount = route.amount, - onPaymentSuccess = { paymentHash -> + onPaymentSuccess = { paymentHash, amountWithFee -> appViewModel.onSendSuccess( NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.SENT, paymentHashOrTxId = paymentHash, - sats = route.amount, + sats = amountWithFee, ), ) }, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a7bd91864..0385acec2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1160,6 +1160,7 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) + cacheStore.releaseQuickPayReservation(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash)) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentFailed() @@ -1225,7 +1226,19 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) - pendingPaymentRepo.resolve(PendingPaymentResolution.Success(paymentHash)) + cacheStore.clearQuickPayReservation(paymentHash) + val amountWithFeeSats = activityRepo.findActivityByPaymentId( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + retry = true, + ).getOrNull()?.totalValue()?.toLong() + pendingPaymentRepo.resolve( + PendingPaymentResolution.Success( + paymentHash = paymentHash, + amountWithFeeSats = amountWithFeeSats, + ), + ) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentSucceeded() } @@ -2678,15 +2691,11 @@ class AppViewModel @Inject constructor( val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return false val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() - val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() - ?: return false - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull()?.value?.toDouble() - ?: return false - val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) - if (spentUsdToday + amountUsd <= dailyCapUsd) return true + val spentSatsToday = cacheStore.quickPaySpentSatsForDay(quickPaySpendDayKey()).toULong() + if (spentSatsToday + amountSats <= dailyCapSats) return true Logger.info( - "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + "Skipping QuickPay: daily spend '$spentSatsToday' + '$amountSats' exceeds cap '$dailyCapSats'", context = TAG, ) return false diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 3fb41e0ae..a3383fd37 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -54,11 +54,11 @@ class QuickPayViewModel @Inject constructor( viewModelScope.launch { val prepared = preparePayment(data) ?: return@launch val dayKey = quickPaySpendDayKey() - if (!reserveSpend(prepared.amountUsd, dayKey)) return@launch + if (!reserveSpend(prepared.displaySats, dayKey)) return@launch sendLightning(prepared.bolt11, prepared.amount) .onSuccess { onPaymentSuccess(it.paymentHash, prepared.displaySats, it.feePaidSats) } - .onFailure { onPaymentFailure(it, prepared.displaySats, prepared.amountUsd, dayKey) } + .onFailure { onPaymentFailure(it, prepared.displaySats, dayKey) } } } @@ -81,23 +81,22 @@ class QuickPayViewModel @Inject constructor( Triple(invoice.bolt11, null, data.sats) } } - val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), USD).getOrNull()?.value?.toDouble() - if (amountUsd == null) { - setError(QuickPayCurrencyConversionError()) - return null - } - return PreparedQuickPay(bolt11, amount, displaySats, amountUsd) + return PreparedQuickPay(bolt11, amount, displaySats) } - private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { - val dailyCapUsd = resolveDailyCapUsd() - if (dailyCapUsd == null) { + private suspend fun reserveSpend(amountSats: ULong, dayKey: String): Boolean { + val dailyCapSats = resolveDailyCapSats() + if (dailyCapSats == null) { setError(QuickPayCurrencyConversionError()) return false } - val reserved = cacheStore.tryReserveQuickPaySpendUsd(amountUsd, dayKey, dailyCapUsd) + val reserved = cacheStore.tryReserveQuickPaySpendSats( + amountSats = amountSats.toLong(), + dayKey = dayKey, + dailyCapSats = dailyCapSats.toLong(), + ) if (!reserved) { - Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountUsd'", context = TAG) + Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountSats'", context = TAG) setError(QuickPayDailyLimitReachedError()) } return reserved @@ -118,12 +117,16 @@ class QuickPayViewModel @Inject constructor( private suspend fun onPaymentFailure( error: Throwable, displaySats: ULong, - amountUsd: Double, dayKey: String, ) { if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) pendingPaymentRepo.track(error.paymentHash) + cacheStore.rememberQuickPayReservation( + paymentHash = error.paymentHash, + amountSats = displaySats.toLong(), + dayKey = dayKey, + ) _uiState.update { it.copy( result = QuickPayResult.Pending( @@ -135,7 +138,7 @@ class QuickPayViewModel @Inject constructor( return } Logger.error("QuickPay lightning payment failed", error, context = TAG) - cacheStore.releaseQuickPaySpendUsd(amountUsd, dayKey) + cacheStore.releaseQuickPaySpendSats(displaySats.toLong(), dayKey) setError(error) } @@ -149,12 +152,11 @@ class QuickPayViewModel @Inject constructor( else -> error.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.common__error_body) } - private suspend fun resolveDailyCapUsd(): Double? { + private suspend fun resolveDailyCapSats(): ULong? { val settings = settingsStore.data.first() val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return null - val dailyCapSats = thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() - return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() + return thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() } private suspend fun sendLightning( @@ -194,7 +196,6 @@ private data class PreparedQuickPay( val bolt11: String, val amount: ULong?, val displaySats: ULong, - val amountUsd: Double, ) private data class SettledQuickPayPayment( diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index c595d7f47..6083042a3 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -93,42 +93,72 @@ class CacheStoreTest : BaseUnitTest() { } @Test - fun `quickPaySpentUsdForDay returns spend for matching day key`() = test { - sut.recordQuickPaySpendUsd(amountUsd = 3.5, dayKey = "2026-08-15") + fun `quickPaySpentSatsForDay returns spend for matching day key`() = test { + sut.recordQuickPaySpendSats(amountSats = 3500L, dayKey = "2026-08-15") - assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) + assertEquals(3500L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test - fun `quickPaySpentUsdForDay returns zero for a different day key`() = test { - sut.recordQuickPaySpendUsd(amountUsd = 12.0, dayKey = "2026-08-14") + fun `quickPaySpentSatsForDay returns zero for a later day key`() = test { + sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-14") - assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test - fun `recordQuickPaySpendUsd accumulates on the same day and resets on a new day`() = test { - sut.recordQuickPaySpendUsd(amountUsd = 2.0, dayKey = "2026-08-15") - sut.recordQuickPaySpendUsd(amountUsd = 1.5, dayKey = "2026-08-15") - assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) - - sut.recordQuickPaySpendUsd(amountUsd = 4.0, dayKey = "2026-08-16") - assertEquals(4.0, sut.quickPaySpentUsdForDay("2026-08-16")) - assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + fun `quickPaySpentSatsForDay keeps spend on clock rollback`() = test { + sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-15") + + assertEquals(12_000L, sut.quickPaySpentSatsForDay("2026-08-14")) + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 1_000L, dayKey = "2026-08-14", dailyCapSats = 20_000L)) + assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-14")) + assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + } + + @Test + fun `recordQuickPaySpendSats accumulates on the same day and resets on a new day`() = test { + sut.recordQuickPaySpendSats(amountSats = 2_000L, dayKey = "2026-08-15") + sut.recordQuickPaySpendSats(amountSats = 1_500L, dayKey = "2026-08-15") + assertEquals(3_500L, sut.quickPaySpentSatsForDay("2026-08-15")) + + sut.recordQuickPaySpendSats(amountSats = 4_000L, dayKey = "2026-08-16") + assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-16")) + assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + } + + @Test + fun `tryReserveQuickPaySpendSats reserves under the cap and rejects over it`() = test { + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertFalse(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertEquals(20_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test - fun `tryReserveQuickPaySpendUsd reserves under the cap and rejects over it`() = test { - assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) - assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) - assertFalse(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) - assertEquals(20.0, sut.quickPaySpentUsdForDay("2026-08-15")) + fun `releaseQuickPaySpendSats rolls back a reservation`() = test { + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + sut.releaseQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15") + assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test - fun `releaseQuickPaySpendUsd rolls back a reservation`() = test { - assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) - sut.releaseQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15") - assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + fun `releaseQuickPayReservation frees pending spend by payment hash`() = test { + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") + + sut.releaseQuickPayReservation("abc") + + assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + } + + @Test + fun `clearQuickPayReservation keeps spend after success`() = test { + assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") + + sut.clearQuickPayReservation("abc") + + assertEquals(5_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } } diff --git a/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt b/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt new file mode 100644 index 000000000..0e19a67be --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/components/StepSliderMappingTest.kt @@ -0,0 +1,25 @@ +package to.bitkit.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals + +class StepSliderMappingTest { + + @Test + fun `pointer mapping mirrors physical x in rtl`() { + assertEquals(20f, sliderPointerToLogical(x = 80f, width = 100f, isRtl = true)) + assertEquals(80f, sliderPointerToLogical(x = 80f, width = 100f, isRtl = false)) + } + + @Test + fun `visual mapping mirrors logical x in rtl`() { + assertEquals(20f, sliderLogicalToVisual(x = 80f, width = 100f, isRtl = true)) + assertEquals(80f, sliderLogicalToVisual(x = 80f, width = 100f, isRtl = false)) + } + + @Test + fun `drag delta flips in rtl so thumb follows the finger`() { + assertEquals(-12f, sliderDragDeltaToLogical(deltaX = 12f, isRtl = true)) + assertEquals(12f, sliderDragDeltaToLogical(deltaX = 12f, isRtl = false)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 3fb56fff6..7b7dbc6f2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -225,7 +225,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) - whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(0.0) + whenever { cacheStore.quickPaySpentSatsForDay(any()) }.thenReturn(0L) + whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) + whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) + whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } + .thenReturn(Result.failure(Exception("activity not found"))) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever { blocktankRepo.refreshInfo() }.thenReturn(Result.success(Unit)) @@ -1718,6 +1722,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) + verify(cacheStore).clearQuickPayReservation(paymentHash) } @Test @@ -1738,6 +1743,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Failure(paymentHash)) + verify(cacheStore).releaseQuickPayReservation(paymentHash) assertNull(pendingContactPaymentContext(paymentHash)) } @@ -2206,7 +2212,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { val bolt11 = "lnbcrt1quickpaycap" - enableQuickPay(thresholdSats = 1000u, spentUsdToday = 24.0) + enableQuickPay(thresholdSats = 1000u, spentSatsToday = 4_600L) settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.setIsAuthenticated(true) @@ -3227,7 +3233,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun enableQuickPay( thresholdSats: ULong, - spentUsdToday: Double = 0.0, + spentSatsToday: Long = 0L, ) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) @@ -3244,7 +3250,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { locale = Locale.US, ) } - whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(spentUsdToday) + whenever { cacheStore.quickPaySpentSatsForDay(any()) }.thenReturn(spentSatsToday) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { From a254ba254f6cd88b3862e5724e90966841759e4a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 21:51:03 +0200 Subject: [PATCH 16/29] test: fix quickpay day spend assertion --- app/src/test/java/to/bitkit/data/CacheStoreTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 6083042a3..942fba825 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -124,7 +124,7 @@ class CacheStoreTest : BaseUnitTest() { sut.recordQuickPaySpendSats(amountSats = 4_000L, dayKey = "2026-08-16") assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-16")) - assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test From d98ad1748306d601ef58ac8a819b7d369527d7ef Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 21:56:17 +0200 Subject: [PATCH 17/29] fix: wrap cache store test lines --- .../java/to/bitkit/data/CacheStoreTest.kt | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 942fba825..b6e122490 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -111,7 +111,13 @@ class CacheStoreTest : BaseUnitTest() { sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-15") assertEquals(12_000L, sut.quickPaySpentSatsForDay("2026-08-14")) - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 1_000L, dayKey = "2026-08-14", dailyCapSats = 20_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 1_000L, + dayKey = "2026-08-14", + dailyCapSats = 20_000L, + ), + ) assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-14")) assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } @@ -129,22 +135,52 @@ class CacheStoreTest : BaseUnitTest() { @Test fun `tryReserveQuickPaySpendSats reserves under the cap and rejects over it`() = test { - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) - assertFalse(sut.tryReserveQuickPaySpendSats(amountSats = 10_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 10_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 10_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) + assertFalse( + sut.tryReserveQuickPaySpendSats( + amountSats = 10_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) assertEquals(20_000L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test fun `releaseQuickPaySpendSats rolls back a reservation`() = test { - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 5_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) sut.releaseQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15") assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) } @Test fun `releaseQuickPayReservation frees pending spend by payment hash`() = test { - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 5_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") sut.releaseQuickPayReservation("abc") @@ -154,7 +190,13 @@ class CacheStoreTest : BaseUnitTest() { @Test fun `clearQuickPayReservation keeps spend after success`() = test { - assertTrue(sut.tryReserveQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15", dailyCapSats = 25_000L)) + assertTrue( + sut.tryReserveQuickPaySpendSats( + amountSats = 5_000L, + dayKey = "2026-08-15", + dailyCapSats = 25_000L, + ), + ) sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") sut.clearQuickPayReservation("abc") From c0806a624e748d78537e2727d876ebecc30cd01b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Aug 2026 16:51:37 +0200 Subject: [PATCH 18/29] fix: account quickpay daily spend in usd cents --- .../main/java/to/bitkit/data/CacheStore.kt | 52 +++-- .../main/java/to/bitkit/models/Currency.kt | 2 + .../wallets/send/SendQuickPayScreen.kt | 2 + .../java/to/bitkit/ui/sheets/SendSheet.kt | 5 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 51 +++-- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 96 +++++---- .../java/to/bitkit/data/CacheStoreTest.kt | 134 +++++++----- .../viewmodels/AppViewModelSendFlowTest.kt | 78 ++++++- .../viewmodels/QuickPayViewModelTest.kt | 198 ++++++++++++++++++ 9 files changed, 488 insertions(+), 130 deletions(-) create mode 100644 app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index e97ade4ba..98d9ac24f 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,63 +139,71 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } - suspend fun quickPaySpentSatsForDay(dayKey: String): Long { + suspend fun quickPaySpentCentsForDay(dayKey: String): Long { val data = store.data.first() - return data.quickPaySpendFor(dayKey).spentSats + return data.quickPaySpendFor(dayKey).spentCents } - suspend fun tryReserveQuickPaySpendSats(amountSats: Long, dayKey: String, dailyCapSats: Long): Boolean { + suspend fun tryReserveQuickPaySpendCents(amountCents: Long, dayKey: String, dailyCapCents: Long): Boolean { var reserved = false store.updateData { val spend = it.quickPaySpendFor(dayKey) - if (spend.spentSats + amountSats > dailyCapSats) return@updateData it + if (spend.spentCents + amountCents > dailyCapCents) return@updateData it reserved = true it.copy( quickPaySpendDayKey = spend.dayKey, - quickPaySpentSatsToday = spend.spentSats + amountSats, + quickPaySpentCentsToday = spend.spentCents + amountCents, ) } return reserved } - suspend fun releaseQuickPaySpendSats(amountSats: Long, dayKey: String) { + suspend fun releaseQuickPaySpendCents(amountCents: Long, dayKey: String) { store.updateData { val spend = it.quickPaySpendFor(dayKey) if (spend.dayKey != it.quickPaySpendDayKey) return@updateData it - it.copy(quickPaySpentSatsToday = (spend.spentSats - amountSats).coerceAtLeast(0L)) + it.copy(quickPaySpentCentsToday = (spend.spentCents - amountCents).coerceAtLeast(0L)) } } - suspend fun recordQuickPaySpendSats(amountSats: Long, dayKey: String) { + suspend fun recordQuickPaySpendCents(amountCents: Long, dayKey: String) { store.updateData { val spend = it.quickPaySpendFor(dayKey) it.copy( quickPaySpendDayKey = spend.dayKey, - quickPaySpentSatsToday = spend.spentSats + amountSats, + quickPaySpentCentsToday = spend.spentCents + amountCents, ) } } - suspend fun rememberQuickPayReservation(paymentHash: String, amountSats: Long, dayKey: String) { + suspend fun rememberQuickPayReservation(paymentHash: String, amountCents: Long, dayKey: String) { if (paymentHash.isBlank()) return store.updateData { it.copy( quickPayReservations = it.quickPayReservations + ( - paymentHash to QuickPaySpendReservation(amountSats = amountSats, dayKey = dayKey) + paymentHash to QuickPaySpendReservation(amountCents = amountCents, dayKey = dayKey) ), ) } } + suspend fun quickPayReservation(paymentHash: String): QuickPaySpendReservation? { + if (paymentHash.isBlank()) return null + return store.data.first().quickPayReservations[paymentHash] + } + suspend fun releaseQuickPayReservation(paymentHash: String) { if (paymentHash.isBlank()) return store.updateData { data -> val reservation = data.quickPayReservations[paymentHash] ?: return@updateData data + val remaining = data.quickPayReservations - paymentHash val spend = data.quickPaySpendFor(reservation.dayKey) + if (reservation.dayKey != spend.dayKey) { + return@updateData data.copy(quickPayReservations = remaining) + } data.copy( - quickPaySpendDayKey = spend.dayKey, - quickPaySpentSatsToday = (spend.spentSats - reservation.amountSats).coerceAtLeast(0L), - quickPayReservations = data.quickPayReservations - paymentHash, + quickPaySpentCentsToday = (spend.spentCents - reservation.amountCents).coerceAtLeast(0L), + quickPayReservations = remaining, ) } } @@ -234,7 +242,7 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), val quickPaySpendDayKey: String = "", - val quickPaySpentSatsToday: Long = 0L, + val quickPaySpentCentsToday: Long = 0L, val quickPayReservations: Map = emptyMap(), ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = @@ -249,18 +257,24 @@ data class AppCacheData( fun quickPaySpendFor(dayKey: String): QuickPayDaySpend = when { quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) - dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentSatsToday) - else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentSatsToday) + dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) + else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday) } } @Serializable data class QuickPaySpendReservation( - val amountSats: Long, + val amountCents: Long, val dayKey: String, ) data class QuickPayDaySpend( val dayKey: String, - val spentSats: Long, + val spentCents: Long, ) + +fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = + thresholdUsd.toLong() * 100L * multiplier.toLong() + +fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = + minOf(convertedCents, thresholdUsd.toLong() * 100L) diff --git a/app/src/main/java/to/bitkit/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 7925a03ed..c74930db7 100644 --- a/app/src/main/java/to/bitkit/models/Currency.kt +++ b/app/src/main/java/to/bitkit/models/Currency.kt @@ -76,6 +76,8 @@ data class ConvertedAmount( val sats: Long, val locale: Locale = Locale.getDefault(), ) { + fun toUsdCents(): Long = value.movePointRight(2).setScale(0, RoundingMode.HALF_UP).toLong() + val isSymbolSuffix: Boolean get() = currency in SUFFIX_SYMBOL_CURRENCIES data class BitcoinDisplayComponents( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt index 5f03bbe4c..75ae2ea49 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendQuickPayScreen.kt @@ -39,6 +39,7 @@ fun SendQuickPayScreen( quickPayData: QuickPayData, onPaymentComplete: (String, Long) -> Unit, onPaymentPending: (String, Long, String) -> Unit, + onFallBackToConfirm: () -> Unit, onShowError: (SendFailureDetails) -> Unit, viewModel: QuickPayViewModel = hiltViewModel(), ) { @@ -59,6 +60,7 @@ fun SendQuickPayScreen( is QuickPayResult.Pending -> { onPaymentPending(result.paymentHash, result.amount, result.paymentRequest) } + is QuickPayResult.FallBackToConfirm -> onFallBackToConfirm() is QuickPayResult.Error -> onShowError(result.failure) null -> Unit // continue showing loading state } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 814aeb999..af475af15 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -351,6 +351,11 @@ fun SendSheet( popUpTo(startDestination) { inclusive = true } } }, + onFallBackToConfirm = { + navController.navigateTo(SendRoute.Confirm) { + popUpTo { inclusive = true } + } + }, onShowError = { failure -> appViewModel.clearActiveContactPaymentContext() navController.navigateTo( diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 01519589c..e366e1ab8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -77,6 +77,8 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.data.quickPayCapCents +import to.bitkit.data.quickPayReserveCents import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyChannelReady @@ -1240,13 +1242,18 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) + val isQuickPay = cacheStore.quickPayReservation(paymentHash) != null cacheStore.clearQuickPayReservation(paymentHash) - val amountWithFeeSats = activityRepo.findActivityByPaymentId( - paymentHashOrTxId = paymentHash, - type = ActivityFilter.LIGHTNING, - txType = PaymentType.SENT, - retry = true, - ).getOrNull()?.totalValue()?.toLong() + val amountWithFeeSats = if (isQuickPay) { + activityRepo.findActivityByPaymentId( + paymentHashOrTxId = paymentHash, + type = ActivityFilter.LIGHTNING, + txType = PaymentType.SENT, + retry = true, + ).getOrNull()?.totalValue()?.toLong() + } else { + null + } pendingPaymentRepo.resolve( PendingPaymentResolution.Success( paymentHash = paymentHash, @@ -2680,6 +2687,26 @@ class AppViewModel @Inject constructor( _quickPayData.update { quickPayData } + if (lnurlPay != null) { + _sendUiState.update { + it.copy( + amount = amountSats, + payMethod = SendMethod.LIGHTNING, + lnurl = LnurlParams.LnurlPay(lnurlPay), + ) + } + } else if (invoice != null) { + _sendUiState.update { + it.copy( + amount = amountSats, + addressInput = invoice.bolt11, + isAddressInputValid = true, + decodedInvoice = invoice, + payMethod = SendMethod.LIGHTNING, + ) + } + } + Logger.debug("QuickPayData: $quickPayData", context = TAG) navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) @@ -2702,14 +2729,14 @@ class AppViewModel @Inject constructor( } private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() - ?: return false - val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() - val spentSatsToday = cacheStore.quickPaySpentSatsForDay(quickPaySpendDayKey()).toULong() - if (spentSatsToday + amountSats <= dailyCapSats) return true + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return false + val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + val spentCentsToday = cacheStore.quickPaySpentCentsForDay(quickPaySpendDayKey()) + if (spentCentsToday + reserveCents <= capCents) return true Logger.info( - "Skipping QuickPay: daily spend '$spentSatsToday' + '$amountSats' exceeds cap '$dailyCapSats'", + "Skipping QuickPay: daily spend '$spentCentsToday' + '$reserveCents' exceeds cap '$capCents'", context = TAG, ) return false diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index de888d104..f99c029ab 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -16,6 +16,8 @@ import org.lightningdevkit.ldknode.PaymentId import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.data.quickPayCapCents +import to.bitkit.data.quickPayReserveCents import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.quickPaySpendDayKey @@ -55,37 +57,43 @@ class QuickPayViewModel @Inject constructor( val lightningState = lightningRepo.lightningState fun pay(data: QuickPayData) { - viewModelScope.launch { - val invoice = resolveQuickPayInvoice(data) ?: return@launch - val dayKey = quickPaySpendDayKey() - if (!reserveSpend(invoice.displaySats, dayKey)) return@launch + viewModelScope.launch { payNow(data) } + } - sendLightning(invoice.bolt11, invoice.amount) - .onSuccess { onPaymentSuccess(it.paymentHash, invoice.displaySats, it.feePaidSats) } - .onFailure { onPaymentFailure(it, invoice, dayKey) } - } + internal suspend fun payNow(data: QuickPayData) { + val invoice = resolveQuickPayInvoice(data) ?: return + val dayKey = quickPaySpendDayKey() + val reservedCents = reserveSpend(invoice.displaySats, dayKey) ?: return + + sendLightning(invoice, reservedCents, dayKey) + .onSuccess { onPaymentSuccess(it.paymentHash, invoice.displaySats, it.feePaidSats) } + .onFailure { onPaymentFailure(it, invoice, reservedCents, dayKey) } } - private suspend fun reserveSpend(amountSats: ULong, dayKey: String): Boolean { - val dailyCapSats = resolveDailyCapSats() - if (dailyCapSats == null) { + private suspend fun reserveSpend(amountSats: ULong, dayKey: String): Long? { + val settings = settingsStore.data.first() + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() + if (converted == null) { setError(QuickPayCurrencyConversionError()) - return false + return null } - val reserved = cacheStore.tryReserveQuickPaySpendSats( - amountSats = amountSats.toLong(), + val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val reserved = cacheStore.tryReserveQuickPaySpendCents( + amountCents = reserveCents, dayKey = dayKey, - dailyCapSats = dailyCapSats.toLong(), + dailyCapCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier), ) if (!reserved) { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountSats'", context = TAG) - setError(QuickPayDailyLimitReachedError()) + _uiState.update { it.copy(result = QuickPayResult.FallBackToConfirm) } + return null } - return reserved + return reserveCents } - private fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { + private suspend fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { Logger.info("QuickPay lightning payment successful") + cacheStore.clearQuickPayReservation(paymentHash) _uiState.update { it.copy( result = QuickPayResult.Success( @@ -99,16 +107,11 @@ class QuickPayViewModel @Inject constructor( private suspend fun onPaymentFailure( error: Throwable, invoice: QuickPayInvoice, + reservedCents: Long, dayKey: String, ) { if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) - pendingPaymentRepo.track(error.paymentHash) - cacheStore.rememberQuickPayReservation( - paymentHash = error.paymentHash, - amountSats = invoice.displaySats.toLong(), - dayKey = dayKey, - ) _uiState.update { it.copy( result = QuickPayResult.Pending( @@ -121,7 +124,11 @@ class QuickPayViewModel @Inject constructor( return } Logger.error("QuickPay lightning payment failed", error, context = TAG) - cacheStore.releaseQuickPaySpendSats(invoice.displaySats.toLong(), dayKey) + if (error is QuickPayPaymentFailedError) { + cacheStore.releaseQuickPayReservation(error.paymentHash) + } else { + cacheStore.releaseQuickPaySpendCents(reservedCents, dayKey) + } handleQuickPayFailure(error, invoice) } @@ -130,9 +137,6 @@ class QuickPayViewModel @Inject constructor( is QuickPayCurrencyConversionError -> { context.getString(R.string.wallet__send_quickpay__currency_conversion) } - is QuickPayDailyLimitReachedError -> { - context.getString(R.string.wallet__send_quickpay__daily_limit) - } else -> null } val failure = if (localizedMessage != null) { @@ -148,13 +152,6 @@ class QuickPayViewModel @Inject constructor( _uiState.update { it.copy(result = QuickPayResult.Error(failure)) } } - private suspend fun resolveDailyCapSats(): ULong? { - val settings = settingsStore.data.first() - val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() - ?: return null - return thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() - } - private suspend fun resolveQuickPayInvoice(data: QuickPayData): QuickPayInvoice? { return when (data) { is QuickPayData.Bolt11 -> { @@ -195,15 +192,22 @@ class QuickPayViewModel @Inject constructor( } private suspend fun sendLightning( - bolt11: String, - amount: ULong? = null, + invoice: QuickPayInvoice, + reservedCents: Long, + dayKey: String, ): Result { - val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount) + val hash = lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = invoice.amount) .onFailure { exception -> return Result.failure(exception) } .getOrDefault("") + cacheStore.rememberQuickPayReservation( + paymentHash = hash, + amountCents = reservedCents, + dayKey = dayKey, + ) + // Wait until matching payment event is received (with timeout for hold invoices) val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { when (it) { @@ -218,14 +222,21 @@ class QuickPayViewModel @Inject constructor( is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( Result.failure( - QuickPayPaymentFailedError(reason = it.reason, paymentRequest = bolt11) + QuickPayPaymentFailedError( + paymentHash = hash, + reason = it.reason, + paymentRequest = invoice.bolt11, + ) ) ) else -> WatchResult.Continue() } } - return result ?: Result.failure(PaymentPendingException(hash)) + if (result != null) return result + + pendingPaymentRepo.track(hash) + return Result.failure(PaymentPendingException(hash)) } } @@ -236,8 +247,6 @@ private data class SettledQuickPayPayment( private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") -private class QuickPayDailyLimitReachedError : AppError("Daily QuickPay limit reached") - sealed class QuickPayResult { data class Success( val paymentHash: String, @@ -250,6 +259,8 @@ sealed class QuickPayResult { val paymentRequest: String, ) : QuickPayResult() + data object FallBackToConfirm : QuickPayResult() + data class Error(val failure: SendFailureDetails) : QuickPayResult() } @@ -267,6 +278,7 @@ private data class QuickPayInvoice( } private class QuickPayPaymentFailedError( + val paymentHash: String, val reason: PaymentFailureReason?, val paymentRequest: String?, ) : AppError(reason?.name) diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index b6e122490..4cc1ad77d 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -11,10 +11,12 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.di.json import to.bitkit.ext.scopedActivityId import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @Config(application = Application::class, sdk = [34]) @@ -93,114 +95,148 @@ class CacheStoreTest : BaseUnitTest() { } @Test - fun `quickPaySpentSatsForDay returns spend for matching day key`() = test { - sut.recordQuickPaySpendSats(amountSats = 3500L, dayKey = "2026-08-15") + fun `quickPaySpentCentsForDay returns spend for matching day key`() = test { + sut.recordQuickPaySpendCents(amountCents = 3500L, dayKey = "2026-08-15") - assertEquals(3500L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(3500L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `quickPaySpentSatsForDay returns zero for a later day key`() = test { - sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-14") + fun `quickPaySpentCentsForDay returns zero for a later day key`() = test { + sut.recordQuickPaySpendCents(amountCents = 12_000L, dayKey = "2026-08-14") - assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `quickPaySpentSatsForDay keeps spend on clock rollback`() = test { - sut.recordQuickPaySpendSats(amountSats = 12_000L, dayKey = "2026-08-15") + fun `quickPaySpentCentsForDay keeps spend on clock rollback`() = test { + sut.recordQuickPaySpendCents(amountCents = 12_000L, dayKey = "2026-08-15") - assertEquals(12_000L, sut.quickPaySpentSatsForDay("2026-08-14")) + assertEquals(12_000L, sut.quickPaySpentCentsForDay("2026-08-14")) assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 1_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 1_000L, dayKey = "2026-08-14", - dailyCapSats = 20_000L, + dailyCapCents = 20_000L, ), ) - assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-14")) - assertEquals(13_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(13_000L, sut.quickPaySpentCentsForDay("2026-08-14")) + assertEquals(13_000L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `recordQuickPaySpendSats accumulates on the same day and resets on a new day`() = test { - sut.recordQuickPaySpendSats(amountSats = 2_000L, dayKey = "2026-08-15") - sut.recordQuickPaySpendSats(amountSats = 1_500L, dayKey = "2026-08-15") - assertEquals(3_500L, sut.quickPaySpentSatsForDay("2026-08-15")) - - sut.recordQuickPaySpendSats(amountSats = 4_000L, dayKey = "2026-08-16") - assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-16")) - assertEquals(4_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + fun `recordQuickPaySpendCents accumulates on the same day and resets on a new day`() = test { + sut.recordQuickPaySpendCents(amountCents = 2_000L, dayKey = "2026-08-15") + sut.recordQuickPaySpendCents(amountCents = 1_500L, dayKey = "2026-08-15") + assertEquals(3_500L, sut.quickPaySpentCentsForDay("2026-08-15")) + + sut.recordQuickPaySpendCents(amountCents = 4_000L, dayKey = "2026-08-16") + assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-16")) + assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `tryReserveQuickPaySpendSats reserves under the cap and rejects over it`() = test { + fun `tryReserveQuickPaySpendCents reserves under the cap and rejects over it`() = test { assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 10_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 10_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 10_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 10_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) assertFalse( - sut.tryReserveQuickPaySpendSats( - amountSats = 10_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 10_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) - assertEquals(20_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(20_000L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test - fun `releaseQuickPaySpendSats rolls back a reservation`() = test { + fun `releaseQuickPaySpendCents rolls back a reservation`() = test { assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 5_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 5_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) - sut.releaseQuickPaySpendSats(amountSats = 5_000L, dayKey = "2026-08-15") - assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + sut.releaseQuickPaySpendCents(amountCents = 5_000L, dayKey = "2026-08-15") + assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test fun `releaseQuickPayReservation frees pending spend by payment hash`() = test { assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 5_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 5_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) - sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") + sut.rememberQuickPayReservation(paymentHash = "abc", amountCents = 5_000L, dayKey = "2026-08-15") sut.releaseQuickPayReservation("abc") - assertEquals(0L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) } @Test fun `clearQuickPayReservation keeps spend after success`() = test { assertTrue( - sut.tryReserveQuickPaySpendSats( - amountSats = 5_000L, + sut.tryReserveQuickPaySpendCents( + amountCents = 5_000L, dayKey = "2026-08-15", - dailyCapSats = 25_000L, + dailyCapCents = 25_000L, ), ) - sut.rememberQuickPayReservation(paymentHash = "abc", amountSats = 5_000L, dayKey = "2026-08-15") + sut.rememberQuickPayReservation(paymentHash = "abc", amountCents = 5_000L, dayKey = "2026-08-15") sut.clearQuickPayReservation("abc") - assertEquals(5_000L, sut.quickPaySpentSatsForDay("2026-08-15")) + assertEquals(5_000L, sut.quickPaySpentCentsForDay("2026-08-15")) + } + + @Test + fun `releaseQuickPayReservation on a prior day does not decrement the new day`() = test { + assertTrue( + sut.tryReserveQuickPaySpendCents( + amountCents = 5_000L, + dayKey = "2026-08-15", + dailyCapCents = 25_000L, + ), + ) + sut.rememberQuickPayReservation(paymentHash = "old", amountCents = 5_000L, dayKey = "2026-08-15") + assertTrue( + sut.tryReserveQuickPaySpendCents( + amountCents = 4_000L, + dayKey = "2026-08-16", + dailyCapCents = 25_000L, + ), + ) + + sut.releaseQuickPayReservation("old") + + assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-16")) + assertNull(sut.quickPayReservation("old")) + } + + @Test + fun `old sat spend field is not read as cents`() { + val data = json.decodeFromString( + """{"quickPaySpendDayKey":"2026-08-15","quickPaySpentSatsToday":20000}""", + ) + + assertEquals(0L, data.quickPaySpentCentsToday) + assertEquals("2026-08-15", data.quickPaySpendDayKey) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index eebe9b6af..2f607aa5f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.nfc.NfcAdapter import androidx.core.net.toUri import app.cash.turbine.test +import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner @@ -41,6 +42,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.atLeast import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -55,6 +57,7 @@ import to.bitkit.CurrentActivity import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore +import to.bitkit.data.QuickPaySpendReservation import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -227,7 +230,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) - whenever { cacheStore.quickPaySpentSatsForDay(any()) }.thenReturn(0L) + whenever { cacheStore.quickPaySpentCentsForDay(any()) }.thenReturn(0L) + whenever { cacheStore.quickPayReservation(any()) }.thenReturn(null) whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } @@ -1754,6 +1758,64 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pendingContactPaymentContext(paymentHash)) } + @Test + fun `pending confirm lightning success keeps invoice amount`() = test { + val paymentHash = "pending_confirm_hash" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever { cacheStore.quickPayReservation(paymentHash) }.thenReturn(null) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) + verify(activityRepo, never()).findActivityByPaymentId(any(), any(), any(), any()) + } + + @Test + fun `pending quickpay lightning success includes settled amount`() = test { + val paymentHash = "pending_quickpay_hash" + val activityV1 = mock { + on { value } doReturn 500u + on { fee } doReturn 10u + } + val activity = mock { on { v1 } doReturn activityV1 } + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever { cacheStore.quickPayReservation(paymentHash) }.thenReturn( + QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15"), + ) + whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } + .thenReturn(Result.success(activity)) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(pendingPaymentRepo).resolve( + PendingPaymentResolution.Success( + paymentHash = paymentHash, + amountWithFeeSats = 510L, + ), + ) + verify(cacheStore).clearQuickPayReservation(paymentHash) + } + @Test fun `active lightning send failure navigates to failure screen`() = test { val bolt11 = "lnbcrt1activefailure" @@ -2181,8 +2243,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) - assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) - assertNull(sut.sendUiState.value.decodedInvoice) + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertEquals(bolt11, sut.sendUiState.value.decodedInvoice?.bolt11) assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @@ -2196,8 +2258,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) - assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) - assertNull(sut.sendUiState.value.decodedInvoice) + assertEquals(SendMethod.LIGHTNING, sut.sendUiState.value.payMethod) + assertEquals(bolt11, sut.sendUiState.value.decodedInvoice?.bolt11) assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @@ -2237,7 +2299,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { val bolt11 = "lnbcrt1quickpaycap" - enableQuickPay(thresholdSats = 1000u, spentSatsToday = 4_600L) + enableQuickPay(thresholdSats = 1000u, spentCentsToday = 2_300L) settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.setIsAuthenticated(true) @@ -3258,7 +3320,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun enableQuickPay( thresholdSats: ULong, - spentSatsToday: Long = 0L, + spentCentsToday: Long = 0L, ) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) @@ -3275,7 +3337,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { locale = Locale.US, ) } - whenever { cacheStore.quickPaySpentSatsForDay(any()) }.thenReturn(spentSatsToday) + whenever { cacheStore.quickPaySpentCentsForDay(any()) }.thenReturn(spentCentsToday) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt new file mode 100644 index 000000000..fc82948b8 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -0,0 +1,198 @@ +package to.bitkit.viewmodels + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.setMain +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.PaymentFailureReason +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.R +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.ext.quickPaySpendDayKey +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.NodeLifecycleState +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class QuickPayViewModelTest : BaseUnitTest() { + private val context: Context = mock() + private val lightningRepo: LightningRepo = mock() + private val pendingPaymentRepo: PendingPaymentRepo = mock() + private val currencyRepo: CurrencyRepo = mock() + private val cacheStore: CacheStore = mock() + private val settingsStore: SettingsStore = mock() + + private lateinit var nodeEvents: MutableSharedFlow + private val settingsData = MutableStateFlow( + SettingsData(isQuickPayEnabled = true, quickPayAmount = 5, quickPayDailyLimitMultiplier = 5), + ) + + private lateinit var sut: QuickPayViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + nodeEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 8) + whenever(context.getString(any())).thenReturn("error") + whenever(context.getString(R.string.wallet__send_quickpay__currency_conversion)).thenReturn("conversion") + whenever(lightningRepo.lightningState).thenReturn( + MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running)), + ) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(settingsStore.data).thenReturn(settingsData) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> + val sats = invocation.getArgument(0) + val usd = 5.0 * sats.toDouble() / 1000.0 + ConvertedAmount( + value = BigDecimal.valueOf(usd), + formatted = usd.toString(), + symbol = "$", + currency = "USD", + flag = "", + sats = sats, + locale = Locale.US, + ) + } + whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(true) + whenever { cacheStore.rememberQuickPayReservation(any(), any(), any()) }.thenReturn(Unit) + whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) + whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) + whenever { cacheStore.releaseQuickPaySpendCents(any(), any()) }.thenReturn(Unit) + sut = QuickPayViewModel( + context = context, + lightningRepo = lightningRepo, + pendingPaymentRepo = pendingPaymentRepo, + currencyRepo = currencyRepo, + cacheStore = cacheStore, + settingsStore = settingsStore, + ) + } + + @Test + fun `happy path reserves before payInvoice and clears reservation on success`() = test { + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "pid", + paymentHash = "hash1", + paymentPreimage = "preimage", + feePaidMsat = 1_000uL, + ), + ) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + val order = inOrder(cacheStore, lightningRepo) + order.verify(cacheStore).tryReserveQuickPaySpendCents( + amountCents = 250L, + dayKey = quickPaySpendDayKey(), + dailyCapCents = 2_500L, + ) + order.verify(lightningRepo).payInvoice(bolt11 = "lnbcrt1test", sats = null) + order.verify(cacheStore).rememberQuickPayReservation( + paymentHash = "hash1", + amountCents = 250L, + dayKey = quickPaySpendDayKey(), + ) + order.verify(cacheStore).clearQuickPayReservation("hash1") + verify(pendingPaymentRepo, never()).track(any()) + val success = assertIs(sut.uiState.value.result) + assertEquals("hash1", success.paymentHash) + assertEquals(501L, success.amountWithFee) + } + + @Test + fun `timeout remembers reservation before tracking pending`() = test { + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT.inWholeMilliseconds + 1) + advanceUntilIdle() + + val order = inOrder(cacheStore, pendingPaymentRepo) + order.verify(cacheStore).rememberQuickPayReservation( + paymentHash = "hash1", + amountCents = 250L, + dayKey = quickPaySpendDayKey(), + ) + order.verify(pendingPaymentRepo).track("hash1") + val pending = assertIs(sut.uiState.value.result) + assertEquals("hash1", pending.paymentHash) + } + + @Test + fun `immediate payInvoice failure releases spend without remembering`() = test { + whenever { lightningRepo.payInvoice(any(), anyOrNull()) } + .thenReturn(Result.failure(IllegalStateException("send failed"))) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + verify(cacheStore).releaseQuickPaySpendCents(250L, quickPaySpendDayKey()) + verify(cacheStore, never()).rememberQuickPayReservation(any(), any(), any()) + verify(pendingPaymentRepo, never()).track(any()) + assertIs(sut.uiState.value.result) + } + + @Test + fun `payment failed after submit releases hash keyed reservation`() = test { + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + nodeEvents.emit( + Event.PaymentFailed( + paymentId = "pid", + paymentHash = "hash1", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + verify(cacheStore).rememberQuickPayReservation("hash1", 250L, quickPaySpendDayKey()) + verify(cacheStore).releaseQuickPayReservation("hash1") + assertIs(sut.uiState.value.result) + } + + @Test + fun `reserve failure emits FallBackToConfirm`() = test { + whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(false) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + assertEquals(QuickPayResult.FallBackToConfirm, sut.uiState.value.result) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + assertNull(sut.uiState.value.result.takeIf { it is QuickPayResult.Error }) + } +} From ddf9b71374cafb3e22de35908333177088ac91b0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Aug 2026 17:47:53 +0200 Subject: [PATCH 19/29] test: fix quickpay viewmodel mock setup --- .../test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index fc82948b8..d8726fd2d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -83,10 +83,6 @@ class QuickPayViewModelTest : BaseUnitTest() { ) } whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(true) - whenever { cacheStore.rememberQuickPayReservation(any(), any(), any()) }.thenReturn(Unit) - whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) - whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) - whenever { cacheStore.releaseQuickPaySpendCents(any(), any()) }.thenReturn(Unit) sut = QuickPayViewModel( context = context, lightningRepo = lightningRepo, From 6d7bf1852e9003bcaa84bed63750197396a2ea74 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Aug 2026 22:39:45 +0200 Subject: [PATCH 20/29] refactor: move quickpay spend ledger to repo --- .../main/java/to/bitkit/data/CacheStore.kt | 89 -------- .../to/bitkit/repositories/QuickPayRepo.kt | 162 ++++++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 38 +--- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 55 ++--- .../java/to/bitkit/data/CacheStoreTest.kt | 137 ------------ .../bitkit/repositories/QuickPayRepoTest.kt | 206 ++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 62 ++---- .../viewmodels/QuickPayViewModelTest.kt | 76 ++----- 8 files changed, 433 insertions(+), 392 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt create mode 100644 app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 98d9ac24f..6b1b348de 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,83 +139,6 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } - suspend fun quickPaySpentCentsForDay(dayKey: String): Long { - val data = store.data.first() - return data.quickPaySpendFor(dayKey).spentCents - } - - suspend fun tryReserveQuickPaySpendCents(amountCents: Long, dayKey: String, dailyCapCents: Long): Boolean { - var reserved = false - store.updateData { - val spend = it.quickPaySpendFor(dayKey) - if (spend.spentCents + amountCents > dailyCapCents) return@updateData it - reserved = true - it.copy( - quickPaySpendDayKey = spend.dayKey, - quickPaySpentCentsToday = spend.spentCents + amountCents, - ) - } - return reserved - } - - suspend fun releaseQuickPaySpendCents(amountCents: Long, dayKey: String) { - store.updateData { - val spend = it.quickPaySpendFor(dayKey) - if (spend.dayKey != it.quickPaySpendDayKey) return@updateData it - it.copy(quickPaySpentCentsToday = (spend.spentCents - amountCents).coerceAtLeast(0L)) - } - } - - suspend fun recordQuickPaySpendCents(amountCents: Long, dayKey: String) { - store.updateData { - val spend = it.quickPaySpendFor(dayKey) - it.copy( - quickPaySpendDayKey = spend.dayKey, - quickPaySpentCentsToday = spend.spentCents + amountCents, - ) - } - } - - suspend fun rememberQuickPayReservation(paymentHash: String, amountCents: Long, dayKey: String) { - if (paymentHash.isBlank()) return - store.updateData { - it.copy( - quickPayReservations = it.quickPayReservations + ( - paymentHash to QuickPaySpendReservation(amountCents = amountCents, dayKey = dayKey) - ), - ) - } - } - - suspend fun quickPayReservation(paymentHash: String): QuickPaySpendReservation? { - if (paymentHash.isBlank()) return null - return store.data.first().quickPayReservations[paymentHash] - } - - suspend fun releaseQuickPayReservation(paymentHash: String) { - if (paymentHash.isBlank()) return - store.updateData { data -> - val reservation = data.quickPayReservations[paymentHash] ?: return@updateData data - val remaining = data.quickPayReservations - paymentHash - val spend = data.quickPaySpendFor(reservation.dayKey) - if (reservation.dayKey != spend.dayKey) { - return@updateData data.copy(quickPayReservations = remaining) - } - data.copy( - quickPaySpentCentsToday = (spend.spentCents - reservation.amountCents).coerceAtLeast(0L), - quickPayReservations = remaining, - ) - } - } - - suspend fun clearQuickPayReservation(paymentHash: String) { - if (paymentHash.isBlank()) return - store.updateData { - if (paymentHash !in it.quickPayReservations) return@updateData it - it.copy(quickPayReservations = it.quickPayReservations - paymentHash) - } - } - suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -254,12 +177,6 @@ data class AppCacheData( fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "") fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") - - fun quickPaySpendFor(dayKey: String): QuickPayDaySpend = when { - quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) - dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) - else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday) - } } @Serializable @@ -272,9 +189,3 @@ data class QuickPayDaySpend( val dayKey: String, val spentCents: Long, ) - -fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = - thresholdUsd.toLong() * 100L * multiplier.toLong() - -fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = - minOf(convertedCents, thresholdUsd.toLong() * 100L) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt new file mode 100644 index 000000000..16fb73778 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -0,0 +1,162 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.QuickPayDaySpend +import to.bitkit.data.QuickPaySpendReservation +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.quickPaySpendDayKey +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.USD +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +@Singleton +class QuickPayRepo @Inject constructor( + private val cacheStore: CacheStore, + private val settingsStore: SettingsStore, + private val currencyRepo: CurrencyRepo, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val clock: Clock, +) { + companion object { + private const val TAG = "QuickPayRepo" + } + + suspend fun spentCentsToday(): Result = withContext(ioDispatcher) { + runSuspendCatching { + cacheStore.data.first().spendFor(currentDayKey()).spentCents + } + } + + suspend fun canApply(amountSats: ULong): Result = withContext(ioDispatcher) { + runSuspendCatching { + val settings = settingsStore.data.first() + if (!settings.isQuickPayEnabled || amountSats == 0uL) return@runSuspendCatching false + + val thresholdSats = currencyRepo.convertFiatToSats( + settings.quickPayAmount.toDouble(), + USD, + ).getOrNull() ?: return@runSuspendCatching false + if (amountSats > thresholdSats) return@runSuspendCatching false + + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() + ?: return@runSuspendCatching false + val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + val spentCentsToday = cacheStore.data.first().spendFor(currentDayKey()).spentCents + if (spentCentsToday + reserveCents <= capCents) return@runSuspendCatching true + + Logger.info( + "Skipping QuickPay: daily spend '$spentCentsToday' + '$reserveCents' exceeds cap '$capCents'", + context = TAG, + ) + false + } + } + + suspend fun tryReserve(amountSats: ULong): Result = withContext(ioDispatcher) { + runSuspendCatching { + val settings = settingsStore.data.first() + val converted = requireNotNull( + currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull(), + ) { "Currency conversion failed" } + val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) + val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) + val dayKey = currentDayKey() + var reserved: QuickPaySpendReservation? = null + cacheStore.update { + val spend = it.spendFor(dayKey) + if (spend.spentCents + amountCents > capCents) return@update it + reserved = QuickPaySpendReservation(amountCents = amountCents, dayKey = spend.dayKey) + it.copy( + quickPaySpendDayKey = spend.dayKey, + quickPaySpentCentsToday = spend.spentCents + amountCents, + ) + } + reserved + } + } + + suspend fun remember( + paymentHash: String, + reservation: QuickPaySpendReservation, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching + cacheStore.update { + it.copy( + quickPayReservations = it.quickPayReservations + (paymentHash to reservation), + ) + } + } + } + + suspend fun reservation(paymentHash: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching null + cacheStore.data.first().quickPayReservations[paymentHash] + } + } + + suspend fun release(paymentHash: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching + cacheStore.update { data -> + val reservation = data.quickPayReservations[paymentHash] ?: return@update data + val remaining = data.quickPayReservations - paymentHash + val spend = data.spendFor(reservation.dayKey) + if (reservation.dayKey != spend.dayKey) { + return@update data.copy(quickPayReservations = remaining) + } + data.copy( + quickPaySpentCentsToday = (spend.spentCents - reservation.amountCents).coerceAtLeast(0L), + quickPayReservations = remaining, + ) + } + } + } + + suspend fun releaseUnbound(reservation: QuickPaySpendReservation): Result = withContext(ioDispatcher) { + runSuspendCatching { + cacheStore.update { + if (reservation.dayKey != it.quickPaySpendDayKey) return@update it + it.copy( + quickPaySpentCentsToday = (it.quickPaySpentCentsToday - reservation.amountCents).coerceAtLeast(0L), + ) + } + } + } + + suspend fun clear(paymentHash: String): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (paymentHash.isBlank()) return@runSuspendCatching + cacheStore.update { + if (paymentHash !in it.quickPayReservations) return@update it + it.copy(quickPayReservations = it.quickPayReservations - paymentHash) + } + } + } + + private fun currentDayKey(): String = quickPaySpendDayKey(clock) +} + +fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = + thresholdUsd.toLong() * 100L * multiplier.toLong() + +fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = + minOf(convertedCents, thresholdUsd.toLong() * 100L) + +private fun AppCacheData.spendFor(dayKey: String): QuickPayDaySpend = when { + quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) + dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) + else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday) +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e366e1ab8..c74183f5c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,11 +74,8 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain -import to.bitkit.data.quickPayCapCents -import to.bitkit.data.quickPayReserveCents import to.bitkit.data.resetPin import to.bitkit.di.BgDispatcher import to.bitkit.domain.commands.NotifyChannelReady @@ -100,7 +97,6 @@ import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat -import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.runSuspendCatching @@ -161,6 +157,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo @@ -225,6 +222,7 @@ class AppViewModel @Inject constructor( private val notifyPaymentReceivedHandler: NotifyPaymentReceivedHandler, private val notifyChannelReadyHandler: NotifyChannelReadyHandler, private val cacheStore: CacheStore, + private val quickPayRepo: QuickPayRepo, private val transferRepo: TransferRepo, private val migrationService: MigrationService, private val coreService: CoreService, @@ -1165,7 +1163,7 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) - cacheStore.releaseQuickPayReservation(paymentHash) + quickPayRepo.release(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentFailed() @@ -1242,8 +1240,8 @@ class AppViewModel @Inject constructor( activityRepo.handlePaymentEvent(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) - val isQuickPay = cacheStore.quickPayReservation(paymentHash) != null - cacheStore.clearQuickPayReservation(paymentHash) + val isQuickPay = quickPayRepo.reservation(paymentHash).getOrNull() != null + quickPayRepo.clear(paymentHash) val amountWithFeeSats = if (isQuickPay) { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHash, @@ -2715,31 +2713,7 @@ class AppViewModel @Inject constructor( private suspend fun canApplyQuickPay(amountSats: ULong): Boolean { if (hasActiveContactPaymentContext()) return false - - val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) return false - - return isWithinQuickPayThreshold(amountSats, settings) && isWithinQuickPayDailyCap(amountSats, settings) - } - - private suspend fun isWithinQuickPayThreshold(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() - ?: return false - return amountSats <= quickPayAmountSats - } - - private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { - val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return false - val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) - val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) - val spentCentsToday = cacheStore.quickPaySpentCentsForDay(quickPaySpendDayKey()) - if (spentCentsToday + reserveCents <= capCents) return true - - Logger.info( - "Skipping QuickPay: daily spend '$spentCentsToday' + '$reserveCents' exceeds cap '$capCents'", - context = TAG, - ) - return false + return quickPayRepo.canApply(amountSats).getOrDefault(false) } private fun resetAmountInput() { diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index f99c029ab..61c902ed5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -7,32 +7,26 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsStore -import to.bitkit.data.quickPayCapCents -import to.bitkit.data.quickPayReserveCents +import to.bitkit.data.QuickPaySpendReservation import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats -import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.supportPaymentRequest import to.bitkit.ext.toCompactFailureType import to.bitkit.ext.toSendFailureDetails import to.bitkit.ext.watchUntil import to.bitkit.models.SendFailureDetails -import to.bitkit.models.USD import to.bitkit.models.msatFloorOf import to.bitkit.models.safe -import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.QuickPayRepo import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -42,9 +36,7 @@ class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, - private val currencyRepo: CurrencyRepo, - private val cacheStore: CacheStore, - private val settingsStore: SettingsStore, + private val quickPayRepo: QuickPayRepo, ) : ViewModel() { companion object { @@ -62,38 +54,29 @@ class QuickPayViewModel @Inject constructor( internal suspend fun payNow(data: QuickPayData) { val invoice = resolveQuickPayInvoice(data) ?: return - val dayKey = quickPaySpendDayKey() - val reservedCents = reserveSpend(invoice.displaySats, dayKey) ?: return + val reservation = reserveSpend(invoice.displaySats) ?: return - sendLightning(invoice, reservedCents, dayKey) + sendLightning(invoice, reservation) .onSuccess { onPaymentSuccess(it.paymentHash, invoice.displaySats, it.feePaidSats) } - .onFailure { onPaymentFailure(it, invoice, reservedCents, dayKey) } + .onFailure { onPaymentFailure(it, invoice, reservation) } } - private suspend fun reserveSpend(amountSats: ULong, dayKey: String): Long? { - val settings = settingsStore.data.first() - val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() - if (converted == null) { + private suspend fun reserveSpend(amountSats: ULong): QuickPaySpendReservation? { + val reserved = quickPayRepo.tryReserve(amountSats).getOrElse { setError(QuickPayCurrencyConversionError()) return null } - val reserveCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) - val reserved = cacheStore.tryReserveQuickPaySpendCents( - amountCents = reserveCents, - dayKey = dayKey, - dailyCapCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier), - ) - if (!reserved) { + if (reserved == null) { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountSats'", context = TAG) _uiState.update { it.copy(result = QuickPayResult.FallBackToConfirm) } return null } - return reserveCents + return reserved } private suspend fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { Logger.info("QuickPay lightning payment successful") - cacheStore.clearQuickPayReservation(paymentHash) + quickPayRepo.clear(paymentHash) _uiState.update { it.copy( result = QuickPayResult.Success( @@ -107,8 +90,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun onPaymentFailure( error: Throwable, invoice: QuickPayInvoice, - reservedCents: Long, - dayKey: String, + reservation: QuickPaySpendReservation, ) { if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) @@ -125,9 +107,9 @@ class QuickPayViewModel @Inject constructor( } Logger.error("QuickPay lightning payment failed", error, context = TAG) if (error is QuickPayPaymentFailedError) { - cacheStore.releaseQuickPayReservation(error.paymentHash) + quickPayRepo.release(error.paymentHash) } else { - cacheStore.releaseQuickPaySpendCents(reservedCents, dayKey) + quickPayRepo.releaseUnbound(reservation) } handleQuickPayFailure(error, invoice) } @@ -193,8 +175,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun sendLightning( invoice: QuickPayInvoice, - reservedCents: Long, - dayKey: String, + reservation: QuickPaySpendReservation, ): Result { val hash = lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = invoice.amount) .onFailure { exception -> @@ -202,11 +183,7 @@ class QuickPayViewModel @Inject constructor( } .getOrDefault("") - cacheStore.rememberQuickPayReservation( - paymentHash = hash, - amountCents = reservedCents, - dayKey = dayKey, - ) + quickPayRepo.remember(paymentHash = hash, reservation = reservation) // Wait until matching payment event is received (with timeout for hold invoices) val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 4cc1ad77d..c7dd47f2a 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -16,7 +16,6 @@ import to.bitkit.ext.scopedActivityId import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue @Config(application = Application::class, sdk = [34]) @@ -94,142 +93,6 @@ class CacheStoreTest : BaseUnitTest() { ) } - @Test - fun `quickPaySpentCentsForDay returns spend for matching day key`() = test { - sut.recordQuickPaySpendCents(amountCents = 3500L, dayKey = "2026-08-15") - - assertEquals(3500L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `quickPaySpentCentsForDay returns zero for a later day key`() = test { - sut.recordQuickPaySpendCents(amountCents = 12_000L, dayKey = "2026-08-14") - - assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `quickPaySpentCentsForDay keeps spend on clock rollback`() = test { - sut.recordQuickPaySpendCents(amountCents = 12_000L, dayKey = "2026-08-15") - - assertEquals(12_000L, sut.quickPaySpentCentsForDay("2026-08-14")) - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 1_000L, - dayKey = "2026-08-14", - dailyCapCents = 20_000L, - ), - ) - assertEquals(13_000L, sut.quickPaySpentCentsForDay("2026-08-14")) - assertEquals(13_000L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `recordQuickPaySpendCents accumulates on the same day and resets on a new day`() = test { - sut.recordQuickPaySpendCents(amountCents = 2_000L, dayKey = "2026-08-15") - sut.recordQuickPaySpendCents(amountCents = 1_500L, dayKey = "2026-08-15") - assertEquals(3_500L, sut.quickPaySpentCentsForDay("2026-08-15")) - - sut.recordQuickPaySpendCents(amountCents = 4_000L, dayKey = "2026-08-16") - assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-16")) - assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `tryReserveQuickPaySpendCents reserves under the cap and rejects over it`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 10_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 10_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - assertFalse( - sut.tryReserveQuickPaySpendCents( - amountCents = 10_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - assertEquals(20_000L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `releaseQuickPaySpendCents rolls back a reservation`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 5_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - sut.releaseQuickPaySpendCents(amountCents = 5_000L, dayKey = "2026-08-15") - assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `releaseQuickPayReservation frees pending spend by payment hash`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 5_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - sut.rememberQuickPayReservation(paymentHash = "abc", amountCents = 5_000L, dayKey = "2026-08-15") - - sut.releaseQuickPayReservation("abc") - - assertEquals(0L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `clearQuickPayReservation keeps spend after success`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 5_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - sut.rememberQuickPayReservation(paymentHash = "abc", amountCents = 5_000L, dayKey = "2026-08-15") - - sut.clearQuickPayReservation("abc") - - assertEquals(5_000L, sut.quickPaySpentCentsForDay("2026-08-15")) - } - - @Test - fun `releaseQuickPayReservation on a prior day does not decrement the new day`() = test { - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 5_000L, - dayKey = "2026-08-15", - dailyCapCents = 25_000L, - ), - ) - sut.rememberQuickPayReservation(paymentHash = "old", amountCents = 5_000L, dayKey = "2026-08-15") - assertTrue( - sut.tryReserveQuickPaySpendCents( - amountCents = 4_000L, - dayKey = "2026-08-16", - dailyCapCents = 25_000L, - ), - ) - - sut.releaseQuickPayReservation("old") - - assertEquals(4_000L, sut.quickPaySpentCentsForDay("2026-08-16")) - assertNull(sut.quickPayReservation("old")) - } - @Test fun `old sat spend field is not read as cents`() { val data = json.decodeFromString( diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt new file mode 100644 index 000000000..10c845fa9 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -0,0 +1,206 @@ +package to.bitkit.repositories + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.USD +import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +@Config(application = Application::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class QuickPayRepoTest : BaseUnitTest() { + private val context = ApplicationProvider.getApplicationContext() + private val cacheStore = CacheStore(context) + private val settingsStore: SettingsStore = mock() + private val currencyRepo: CurrencyRepo = mock() + private val clock = MutableClock(Instant.parse("2026-08-15T12:00:00Z")) + private val settingsData = MutableStateFlow( + SettingsData(isQuickPayEnabled = true, quickPayAmount = 5, quickPayDailyLimitMultiplier = 5), + ) + + private lateinit var sut: QuickPayRepo + + @Before + fun setUp() = runBlocking { + cacheStore.reset() + whenever(settingsStore.data).thenReturn(settingsData) + whenever(currencyRepo.convertFiatToSats(5.0, USD)).thenAnswer { 1000uL } + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> + val sats = invocation.getArgument(0) + val usd = 5.0 * sats.toDouble() / 1000.0 + ConvertedAmount( + value = BigDecimal.valueOf(usd), + formatted = usd.toString(), + symbol = "$", + currency = "USD", + flag = "", + sats = sats, + locale = Locale.US, + ) + } + sut = QuickPayRepo( + cacheStore = cacheStore, + settingsStore = settingsStore, + currencyRepo = currencyRepo, + ioDispatcher = testDispatcher, + clock = clock, + ) + } + + @After + fun tearDown() = runBlocking { cacheStore.reset() } + + @Test + fun `spentCentsToday returns spend for matching day`() = test { + assertNotNull(sut.tryReserve(500u).getOrThrow()) + + assertEquals(250L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `spentCentsToday returns zero for a later day`() = test { + assertNotNull(sut.tryReserve(500u).getOrThrow()) + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + + assertEquals(0L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `spentCentsToday keeps spend on clock rollback`() = test { + assertNotNull(sut.tryReserve(500u).getOrThrow()) + clock.instant = Instant.parse("2026-08-14T12:00:00Z") + + assertEquals(250L, sut.spentCentsToday().getOrThrow()) + assertNotNull(sut.tryReserve(200u).getOrThrow()) + assertEquals(350L, sut.spentCentsToday().getOrThrow()) + clock.instant = Instant.parse("2026-08-15T12:00:00Z") + assertEquals(350L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `tryReserve accumulates on the same day and resets on a new day`() = test { + assertNotNull(sut.tryReserve(400u).getOrThrow()) + assertNotNull(sut.tryReserve(300u).getOrThrow()) + assertEquals(350L, sut.spentCentsToday().getOrThrow()) + + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.tryReserve(800u).getOrThrow()) + assertEquals(400L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `tryReserve reserves under the cap and rejects over it`() = test { + settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 2) + assertNotNull(sut.tryReserve(1000u).getOrThrow()) + assertNotNull(sut.tryReserve(1000u).getOrThrow()) + assertNull(sut.tryReserve(1000u).getOrThrow()) + assertEquals(1000L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `releaseUnbound rolls back a reservation`() = test { + val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + + sut.releaseUnbound(reserved).getOrThrow() + + assertEquals(0L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `releaseUnbound on a prior day does not decrement the new day`() = test { + val old = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.tryReserve(800u).getOrThrow()) + + sut.releaseUnbound(old).getOrThrow() + + assertEquals(400L, sut.spentCentsToday().getOrThrow()) + } + + @Test + fun `release frees pending spend by payment hash`() = test { + val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + sut.remember("abc", reserved).getOrThrow() + + sut.release("abc").getOrThrow() + + assertEquals(0L, sut.spentCentsToday().getOrThrow()) + assertNull(sut.reservation("abc").getOrThrow()) + } + + @Test + fun `clear keeps spend after success`() = test { + val reserved = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + sut.remember("abc", reserved).getOrThrow() + + sut.clear("abc").getOrThrow() + + assertEquals(500L, sut.spentCentsToday().getOrThrow()) + assertNull(sut.reservation("abc").getOrThrow()) + } + + @Test + fun `release on a prior day does not decrement the new day`() = test { + val old = requireNotNull(sut.tryReserve(1000u).getOrThrow()) + sut.remember("old", old).getOrThrow() + clock.instant = Instant.parse("2026-08-16T12:00:00Z") + assertNotNull(sut.tryReserve(800u).getOrThrow()) + + sut.release("old").getOrThrow() + + assertEquals(400L, sut.spentCentsToday().getOrThrow()) + assertNull(sut.reservation("old").getOrThrow()) + } + + @Test + fun `canApply is true under threshold and cap`() = test { + assertTrue(sut.canApply(500u).getOrThrow()) + } + + @Test + fun `canApply is false when daily cap would be exceeded`() = test { + settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 1) + assertNotNull(sut.tryReserve(1000u).getOrThrow()) + + assertFalse(sut.canApply(1000u).getOrThrow()) + } + + @Test + fun `canApply is false when disabled`() = test { + settingsData.value = settingsData.value.copy(isQuickPayEnabled = false) + + assertFalse(sut.canApply(500u).getOrThrow()) + } +} + +@OptIn(ExperimentalTime::class) +private class MutableClock(var instant: Instant) : Clock { + override fun now(): Instant = instant +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2f607aa5f..2c2565bff 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -65,7 +65,6 @@ import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState -import to.bitkit.models.ConvertedAmount import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -99,6 +98,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo +import to.bitkit.repositories.QuickPayRepo import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress import to.bitkit.repositories.SettledReceiveInvoice @@ -124,10 +124,8 @@ import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager -import java.math.BigDecimal import java.net.URLEncoder import java.nio.charset.StandardCharsets -import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -162,6 +160,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val notifyPaymentReceivedHandler = mock() private val notifyChannelReadyHandler = mock() private val cacheStore = mock() + private val quickPayRepo = mock() private val transferRepo = mock() private val migrationService = mock() private val coreService = mock() @@ -230,10 +229,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) - whenever { cacheStore.quickPaySpentCentsForDay(any()) }.thenReturn(0L) - whenever { cacheStore.quickPayReservation(any()) }.thenReturn(null) - whenever { cacheStore.clearQuickPayReservation(any()) }.thenReturn(Unit) - whenever { cacheStore.releaseQuickPayReservation(any()) }.thenReturn(Unit) + whenever { quickPayRepo.canApply(org.mockito.kotlin.any()) }.thenReturn(Result.success(false)) + whenever { quickPayRepo.reservation(any()) }.thenReturn(Result.success(null)) + whenever { quickPayRepo.clear(any()) }.thenReturn(Result.success(Unit)) + whenever { quickPayRepo.release(any()) }.thenReturn(Result.success(Unit)) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } .thenReturn(Result.failure(Exception("activity not found"))) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) @@ -334,6 +333,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { notifyPaymentReceivedHandler = notifyPaymentReceivedHandler, notifyChannelReadyHandler = notifyChannelReadyHandler, cacheStore = cacheStore, + quickPayRepo = quickPayRepo, transferRepo = transferRepo, migrationService = migrationService, coreService = coreService, @@ -1728,7 +1728,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) - verify(cacheStore).clearQuickPayReservation(paymentHash) + verify(quickPayRepo).clear(paymentHash) } @Test @@ -1754,7 +1754,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { reason = PaymentFailureReason.RETRIES_EXHAUSTED, ) ) - verify(cacheStore).releaseQuickPayReservation(paymentHash) + verify(quickPayRepo).release(paymentHash) assertNull(pendingContactPaymentContext(paymentHash)) } @@ -1763,7 +1763,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val paymentHash = "pending_confirm_hash" whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) - whenever { cacheStore.quickPayReservation(paymentHash) }.thenReturn(null) + whenever { quickPayRepo.reservation(paymentHash) }.thenReturn(Result.success(null)) advanceUntilIdle() emitNodeEvent( @@ -1790,8 +1790,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val activity = mock { on { v1 } doReturn activityV1 } whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) - whenever { cacheStore.quickPayReservation(paymentHash) }.thenReturn( - QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15"), + whenever { quickPayRepo.reservation(paymentHash) }.thenReturn( + Result.success(QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15")), ) whenever { activityRepo.findActivityByPaymentId(any(), any(), any(), any()) } .thenReturn(Result.success(activity)) @@ -1813,7 +1813,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountWithFeeSats = 510L, ), ) - verify(cacheStore).clearQuickPayReservation(paymentHash) + verify(quickPayRepo).clear(paymentHash) } @Test @@ -2235,7 +2235,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `main scanner lightning scan opens QuickPay when enabled`() = test { val bolt11 = "lnbcrt1scannerquickpay" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.showScannerSheet() @@ -2251,7 +2251,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.onScanResult(bolt11) @@ -2266,7 +2266,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan uses QuickPay when PIN is required for payments under daily cap`() = test { val bolt11 = "lnbcrt1quickpaypin" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() settingsData.value = settingsData.value.copy( isPinEnabled = true, isPinForPaymentsEnabled = true, @@ -2284,7 +2284,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan uses QuickPay when PIN is on without PIN for payments`() = test { val bolt11 = "lnbcrt1quickpayunlocked" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() settingsData.value = settingsData.value.copy(isPinEnabled = true) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.setIsAuthenticated(true) @@ -2299,8 +2299,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { val bolt11 = "lnbcrt1quickpaycap" - enableQuickPay(thresholdSats = 1000u, spentCentsToday = 2_300L) - settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) + enableQuickPay(canApply = false) stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.setIsAuthenticated(true) @@ -2314,7 +2313,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `QuickPay eligible scan remains deferred until authenticated`() = test { val bolt11 = "lnbcrt1lockedscan" - enableQuickPay(thresholdSats = 1_000u) + enableQuickPay() settingsData.value = settingsData.value.copy( isPinEnabled = true, isPinForPaymentsEnabled = true, @@ -2607,7 +2606,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `contact lightning payment skips QuickPay and opens confirm`() = test { val bolt11 = "lnbcrt1contact" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.openContactPayment(paymentRequest = bolt11, publicKey = "pubkycontact") @@ -3318,26 +3317,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - private fun enableQuickPay( - thresholdSats: ULong, - spentCentsToday: Long = 0L, - ) { + private fun enableQuickPay(canApply: Boolean = true) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) - whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) - whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> - val sats = invocation.getArgument(0) - val usd = 5.0 * sats.toDouble() / thresholdSats.toDouble() - ConvertedAmount( - value = BigDecimal.valueOf(usd), - formatted = usd.toString(), - symbol = "$", - currency = "USD", - flag = "", - sats = sats, - locale = Locale.US, - ) - } - whenever { cacheStore.quickPaySpentCentsForDay(any()) }.thenReturn(spentCentsToday) + whenever { quickPayRepo.canApply(org.mockito.kotlin.any()) }.thenReturn(Result.success(canApply)) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index d8726fd2d..4720d4534 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -23,19 +23,13 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.R -import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.ext.quickPaySpendDayKey -import to.bitkit.models.ConvertedAmount +import to.bitkit.data.QuickPaySpendReservation import to.bitkit.models.NodeLifecycleState -import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.QuickPayRepo import to.bitkit.test.BaseUnitTest -import java.math.BigDecimal -import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull @@ -47,14 +41,10 @@ class QuickPayViewModelTest : BaseUnitTest() { private val context: Context = mock() private val lightningRepo: LightningRepo = mock() private val pendingPaymentRepo: PendingPaymentRepo = mock() - private val currencyRepo: CurrencyRepo = mock() - private val cacheStore: CacheStore = mock() - private val settingsStore: SettingsStore = mock() + private val quickPayRepo: QuickPayRepo = mock() private lateinit var nodeEvents: MutableSharedFlow - private val settingsData = MutableStateFlow( - SettingsData(isQuickPayEnabled = true, quickPayAmount = 5, quickPayDailyLimitMultiplier = 5), - ) + private val reserved = QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15") private lateinit var sut: QuickPayViewModel @@ -68,28 +58,16 @@ class QuickPayViewModelTest : BaseUnitTest() { MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running)), ) whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) - whenever(settingsStore.data).thenReturn(settingsData) - whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> - val sats = invocation.getArgument(0) - val usd = 5.0 * sats.toDouble() / 1000.0 - ConvertedAmount( - value = BigDecimal.valueOf(usd), - formatted = usd.toString(), - symbol = "$", - currency = "USD", - flag = "", - sats = sats, - locale = Locale.US, - ) - } - whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(true) + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.success(reserved)) + whenever { quickPayRepo.remember(any(), any()) }.thenReturn(Result.success(Unit)) + whenever { quickPayRepo.clear(any()) }.thenReturn(Result.success(Unit)) + whenever { quickPayRepo.release(any()) }.thenReturn(Result.success(Unit)) + whenever { quickPayRepo.releaseUnbound(any()) }.thenReturn(Result.success(Unit)) sut = QuickPayViewModel( context = context, lightningRepo = lightningRepo, pendingPaymentRepo = pendingPaymentRepo, - currencyRepo = currencyRepo, - cacheStore = cacheStore, - settingsStore = settingsStore, + quickPayRepo = quickPayRepo, ) } @@ -108,19 +86,11 @@ class QuickPayViewModelTest : BaseUnitTest() { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() - val order = inOrder(cacheStore, lightningRepo) - order.verify(cacheStore).tryReserveQuickPaySpendCents( - amountCents = 250L, - dayKey = quickPaySpendDayKey(), - dailyCapCents = 2_500L, - ) + val order = inOrder(quickPayRepo, lightningRepo) + order.verify(quickPayRepo).tryReserve(500u) order.verify(lightningRepo).payInvoice(bolt11 = "lnbcrt1test", sats = null) - order.verify(cacheStore).rememberQuickPayReservation( - paymentHash = "hash1", - amountCents = 250L, - dayKey = quickPaySpendDayKey(), - ) - order.verify(cacheStore).clearQuickPayReservation("hash1") + order.verify(quickPayRepo).remember("hash1", reserved) + order.verify(quickPayRepo).clear("hash1") verify(pendingPaymentRepo, never()).track(any()) val success = assertIs(sut.uiState.value.result) assertEquals("hash1", success.paymentHash) @@ -136,12 +106,8 @@ class QuickPayViewModelTest : BaseUnitTest() { advanceTimeBy(LightningRepo.SEND_LN_TIMEOUT.inWholeMilliseconds + 1) advanceUntilIdle() - val order = inOrder(cacheStore, pendingPaymentRepo) - order.verify(cacheStore).rememberQuickPayReservation( - paymentHash = "hash1", - amountCents = 250L, - dayKey = quickPaySpendDayKey(), - ) + val order = inOrder(quickPayRepo, pendingPaymentRepo) + order.verify(quickPayRepo).remember("hash1", reserved) order.verify(pendingPaymentRepo).track("hash1") val pending = assertIs(sut.uiState.value.result) assertEquals("hash1", pending.paymentHash) @@ -155,8 +121,8 @@ class QuickPayViewModelTest : BaseUnitTest() { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() - verify(cacheStore).releaseQuickPaySpendCents(250L, quickPaySpendDayKey()) - verify(cacheStore, never()).rememberQuickPayReservation(any(), any(), any()) + verify(quickPayRepo).releaseUnbound(reserved) + verify(quickPayRepo, never()).remember(any(), any()) verify(pendingPaymentRepo, never()).track(any()) assertIs(sut.uiState.value.result) } @@ -175,14 +141,14 @@ class QuickPayViewModelTest : BaseUnitTest() { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() - verify(cacheStore).rememberQuickPayReservation("hash1", 250L, quickPaySpendDayKey()) - verify(cacheStore).releaseQuickPayReservation("hash1") + verify(quickPayRepo).remember("hash1", reserved) + verify(quickPayRepo).release("hash1") assertIs(sut.uiState.value.result) } @Test fun `reserve failure emits FallBackToConfirm`() = test { - whenever { cacheStore.tryReserveQuickPaySpendCents(any(), any(), any()) }.thenReturn(false) + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.success(null)) sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() From 1bb04980996b8132918e24926390f46084c0e092 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 19 Aug 2026 23:50:54 +0200 Subject: [PATCH 21/29] chore: cleanup imports --- .../java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2c2565bff..d0604fb40 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -229,7 +229,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) - whenever { quickPayRepo.canApply(org.mockito.kotlin.any()) }.thenReturn(Result.success(false)) + whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(false)) whenever { quickPayRepo.reservation(any()) }.thenReturn(Result.success(null)) whenever { quickPayRepo.clear(any()) }.thenReturn(Result.success(Unit)) whenever { quickPayRepo.release(any()) }.thenReturn(Result.success(Unit)) @@ -3319,7 +3319,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private fun enableQuickPay(canApply: Boolean = true) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) - whenever { quickPayRepo.canApply(org.mockito.kotlin.any()) }.thenReturn(Result.success(canApply)) + whenever { quickPayRepo.canApply(any()) }.thenReturn(Result.success(canApply)) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { From 37017dfd96aa23e28702875245e23cd5c2b17295 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:00:08 +0200 Subject: [PATCH 22/29] fix: keep quickpay events and spend in sync --- .../to/bitkit/repositories/QuickPayRepo.kt | 13 +-- .../java/to/bitkit/ui/sheets/SendSheet.kt | 1 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../to/bitkit/viewmodels/QuickPayViewModel.kt | 68 +++++++------- app/src/main/res/values/strings.xml | 1 - .../bitkit/repositories/QuickPayRepoTest.kt | 11 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 55 ++++++++++++ .../viewmodels/QuickPayViewModelTest.kt | 90 +++++++++++++++++-- 8 files changed, 198 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 16fb73778..8802ddbba 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -12,6 +12,7 @@ import to.bitkit.di.IoDispatcher import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.runSuspendCatching import to.bitkit.models.USD +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -66,9 +67,9 @@ class QuickPayRepo @Inject constructor( suspend fun tryReserve(amountSats: ULong): Result = withContext(ioDispatcher) { runSuspendCatching { val settings = settingsStore.data.first() - val converted = requireNotNull( - currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull(), - ) { "Currency conversion failed" } + val converted = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrElse { + throw QuickPayConversionError() + } val amountCents = quickPayReserveCents(converted.toUsdCents(), settings.quickPayAmount) val capCents = quickPayCapCents(settings.quickPayAmount, settings.quickPayDailyLimitMultiplier) val dayKey = currentDayKey() @@ -149,12 +150,14 @@ class QuickPayRepo @Inject constructor( private fun currentDayKey(): String = quickPaySpendDayKey(clock) } -fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = +private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = thresholdUsd.toLong() * 100L * multiplier.toLong() -fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = +private fun quickPayReserveCents(convertedCents: Long, thresholdUsd: Int): Long = minOf(convertedCents, thresholdUsd.toLong() * 100L) +class QuickPayConversionError : AppError("Currency conversion failed") + private fun AppCacheData.spendFor(dayKey: String): QuickPayDaySpend = when { quickPaySpendDayKey.isEmpty() || dayKey > quickPaySpendDayKey -> QuickPayDaySpend(dayKey, 0L) dayKey == quickPaySpendDayKey -> QuickPayDaySpend(dayKey, quickPaySpentCentsToday) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index af475af15..15753d15a 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -352,6 +352,7 @@ fun SendSheet( } }, onFallBackToConfirm = { + appViewModel.resetQuickPay() navController.navigateTo(SendRoute.Confirm) { popUpTo { inclusive = true } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c74183f5c..120d77e7a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1176,6 +1176,7 @@ class AppViewModel @Inject constructor( } private fun closeActiveSendForFailedPayment(paymentHash: String, reason: PaymentFailureReason?): Boolean { + if (_quickPayData.value != null) return false val activePaymentHash = _sendUiState.value.decodedInvoice?.paymentHash?.toHex() if (_currentSheet.value !is Sheet.Send || activePaymentHash != paymentHash) return false diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 61c902ed5..c2195faca 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -26,6 +29,7 @@ import to.bitkit.models.safe import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.QuickPayConversionError import to.bitkit.repositories.QuickPayRepo import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -47,9 +51,11 @@ class QuickPayViewModel @Inject constructor( val uiState = _uiState.asStateFlow() val lightningState = lightningRepo.lightningState + private var payJob: Job? = null fun pay(data: QuickPayData) { - viewModelScope.launch { payNow(data) } + if (payJob?.isActive == true || _uiState.value.result != null) return + payJob = viewModelScope.launch { payNow(data) } } internal suspend fun payNow(data: QuickPayData) { @@ -63,7 +69,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun reserveSpend(amountSats: ULong): QuickPaySpendReservation? { val reserved = quickPayRepo.tryReserve(amountSats).getOrElse { - setError(QuickPayCurrencyConversionError()) + setError(it) return null } if (reserved == null) { @@ -75,7 +81,7 @@ class QuickPayViewModel @Inject constructor( } private suspend fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { - Logger.info("QuickPay lightning payment successful") + Logger.info("QuickPay lightning payment successful", context = TAG) quickPayRepo.clear(paymentHash) _uiState.update { it.copy( @@ -116,7 +122,7 @@ class QuickPayViewModel @Inject constructor( private fun setError(error: Throwable, paymentRequest: String? = null) { val localizedMessage = when (error) { - is QuickPayCurrencyConversionError -> { + is QuickPayConversionError -> { context.getString(R.string.wallet__send_quickpay__currency_conversion) } else -> null @@ -183,37 +189,39 @@ class QuickPayViewModel @Inject constructor( } .getOrDefault("") - quickPayRepo.remember(paymentHash = hash, reservation = reservation) - - // Wait until matching payment event is received (with timeout for hold invoices) - val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { - when (it) { - is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete( - Result.success( - SettledQuickPayPayment( - paymentHash = hash, - feePaidSats = msatFloorOf(it.feePaidMsat ?: 0u), + return coroutineScope { + val settled = async { + lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { + when (it) { + is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete( + Result.success( + SettledQuickPayPayment( + paymentHash = hash, + feePaidSats = msatFloorOf(it.feePaidMsat ?: 0u), + ) + ) ) - ) - ) - is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure( - QuickPayPaymentFailedError( - paymentHash = hash, - reason = it.reason, - paymentRequest = invoice.bolt11, + is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( + Result.failure( + QuickPayPaymentFailedError( + paymentHash = hash, + reason = it.reason, + paymentRequest = invoice.bolt11, + ) + ) ) - ) - ) - else -> WatchResult.Continue() + else -> WatchResult.Continue() + } + } } + quickPayRepo.remember(paymentHash = hash, reservation = reservation) + val result = settled.await() + if (result != null) return@coroutineScope result + pendingPaymentRepo.track(hash) + Result.failure(PaymentPendingException(hash)) } - if (result != null) return result - - pendingPaymentRepo.track(hash) - return Result.failure(PaymentPendingException(hash)) } } @@ -222,8 +230,6 @@ private data class SettledQuickPayPayment( val feePaidSats: ULong, ) -private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") - sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53c6d360e..57a288533 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1253,7 +1253,6 @@ This payment is taking a bit longer than expected. You can continue using Bitkit. Payment Pending Currency conversion failed - Daily QuickPay limit reached QuickPay Paying\n<accent>invoice...</accent> Confirm diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 10c845fa9..467ebf2ba 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -198,6 +198,17 @@ class QuickPayRepoTest : BaseUnitTest() { assertFalse(sut.canApply(500u).getOrThrow()) } + + @Test + fun `tryReserve fails with conversion error when rates are unavailable`() = test { + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { + throw QuickPayConversionError() + } + + val result = sut.tryReserve(500u) + + assertTrue(result.exceptionOrNull() is QuickPayConversionError) + } } @OptIn(ExperimentalTime::class) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d0604fb40..0d576306b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1858,6 +1858,61 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `in-flight QuickPay failure does not navigate to confirm error`() = test { + val bolt11 = "lnbcrt1quickpayfail" + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.onScanResult(bolt11) + advanceUntilIdle() + + sut.sendEffect.test { + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = "010203", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + expectNoEvents() + } + } + + @Test + fun `confirm failure still navigates after QuickPay fallback`() = test { + val bolt11 = "lnbcrt1quickpayfallback" + val errorMessage = "Bitkit could not find a route" + whenever(context.getString(R.string.wallet__payment_route_not_found)).thenReturn(errorMessage) + enableQuickPay() + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.onScanResult(bolt11) + advanceUntilIdle() + sut.resetQuickPay() + + sut.sendEffect.test { + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = "010203", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + assertEquals( + SendEffect.NavigateToError( + SendFailureDetails( + message = errorMessage, + failureType = "routeNotFound", + resetRoutingCachesOnRetry = true, + paymentRequest = bolt11, + ) + ), + awaitItem(), + ) + } + } + @Test fun `received lightning payment closes the active receive sheet after wallet invoice is cleared`() = test { walletState.value = WalletState(bolt11 = "settled-invoice") diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index 4720d4534..b59f107d0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -1,10 +1,12 @@ package to.bitkit.viewmodels import android.content.Context +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.setMain @@ -15,9 +17,11 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -28,6 +32,7 @@ import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo +import to.bitkit.repositories.QuickPayConversionError import to.bitkit.repositories.QuickPayRepo import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -51,7 +56,7 @@ class QuickPayViewModelTest : BaseUnitTest() { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - nodeEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 8) + nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) whenever(context.getString(any())).thenReturn("error") whenever(context.getString(R.string.wallet__send_quickpay__currency_conversion)).thenReturn("conversion") whenever(lightningRepo.lightningState).thenReturn( @@ -74,6 +79,8 @@ class QuickPayViewModelTest : BaseUnitTest() { @Test fun `happy path reserves before payInvoice and clears reservation on success`() = test { whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + + launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } nodeEvents.emit( Event.PaymentSuccessful( paymentId = "pid", @@ -82,8 +89,6 @@ class QuickPayViewModelTest : BaseUnitTest() { feePaidMsat = 1_000uL, ), ) - - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() val order = inOrder(quickPayRepo, lightningRepo) @@ -130,6 +135,8 @@ class QuickPayViewModelTest : BaseUnitTest() { @Test fun `payment failed after submit releases hash keyed reservation`() = test { whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + + launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } nodeEvents.emit( Event.PaymentFailed( paymentId = "pid", @@ -137,8 +144,6 @@ class QuickPayViewModelTest : BaseUnitTest() { reason = PaymentFailureReason.ROUTE_NOT_FOUND, ), ) - - sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) advanceUntilIdle() verify(quickPayRepo).remember("hash1", reserved) @@ -157,4 +162,79 @@ class QuickPayViewModelTest : BaseUnitTest() { verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) assertNull(sut.uiState.value.result.takeIf { it is QuickPayResult.Error }) } + + @Test + fun `fast fail during remember is collected and released`() = test { + val allowRemember = CompletableDeferred() + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.thenReturn(Result.success("hash1")) + whenever { quickPayRepo.remember(any(), any()) }.doSuspendableAnswer { + allowRemember.await() + Result.success(Unit) + } + + launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } + nodeEvents.emit( + Event.PaymentFailed( + paymentId = "pid", + paymentHash = "hash1", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + allowRemember.complete(Unit) + advanceUntilIdle() + + verify(quickPayRepo).release("hash1") + verify(pendingPaymentRepo, never()).track(any()) + assertIs(sut.uiState.value.result) + } + + @Test + fun `pay ignores re-entry while in flight`() = test { + val allowPay = CompletableDeferred() + whenever { lightningRepo.payInvoice(any(), anyOrNull()) }.doSuspendableAnswer { + allowPay.await() + Result.success("hash1") + } + val data = QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test") + + sut.pay(data) + sut.pay(data) + verify(quickPayRepo, times(1)).tryReserve(any()) + verify(lightningRepo, times(1)).payInvoice(any(), anyOrNull()) + + allowPay.complete(Unit) + nodeEvents.emit( + Event.PaymentSuccessful( + paymentId = "pid", + paymentHash = "hash1", + paymentPreimage = "preimage", + feePaidMsat = 1_000uL, + ), + ) + advanceUntilIdle() + } + + @Test + fun `conversion failure uses currency conversion message`() = test { + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.failure(QuickPayConversionError())) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + val error = assertIs(sut.uiState.value.result) + assertEquals("conversion", error.failure.message) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } + + @Test + fun `non conversion reserve failure is not the currency string`() = test { + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.failure(IllegalStateException("disk"))) + + sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) + advanceUntilIdle() + + val error = assertIs(sut.uiState.value.result) + assertEquals("disk", error.failure.message) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } } From ffe28d9b2ede1a477a90413040d14f1a0ababfd2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:36:18 +0200 Subject: [PATCH 23/29] fix: release spend without pending gate --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 40 +++++++++++++++++++ .../viewmodels/QuickPayViewModelTest.kt | 6 ++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 120d77e7a..a7adba0e7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1161,9 +1161,9 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentFailed(event: Event.PaymentFailed) { event.paymentHash?.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + quickPayRepo.release(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { clearPendingContactPaymentContext(paymentHash) - quickPayRepo.release(paymentHash) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentFailed() @@ -1239,10 +1239,10 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentSuccessful(event: Event.PaymentSuccessful) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + val isQuickPay = quickPayRepo.reservation(paymentHash).getOrNull() != null + quickPayRepo.clear(paymentHash) if (pendingPaymentRepo.isPending(paymentHash)) { syncContactForActivity(paymentHash) - val isQuickPay = quickPayRepo.reservation(paymentHash).getOrNull() != null - quickPayRepo.clear(paymentHash) val amountWithFeeSats = if (isQuickPay) { activityRepo.findActivityByPaymentId( paymentHashOrTxId = paymentHash, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0d576306b..45d181977 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1758,6 +1758,46 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(pendingContactPaymentContext(paymentHash)) } + @Test + fun `PaymentFailed releases disk reservation when not pending`() = test { + val paymentHash = "restart_hash" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) + + emitNodeEvent( + Event.PaymentFailed( + paymentId = "payment_id", + paymentHash = paymentHash, + reason = PaymentFailureReason.RETRIES_EXHAUSTED, + ), + ) + advanceUntilIdle() + + verify(quickPayRepo).release(paymentHash) + verify(pendingPaymentRepo, never()).resolve(any()) + } + + @Test + fun `PaymentSuccessful clears disk reservation when not pending`() = test { + val paymentHash = "restart_ok" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(false) + whenever { quickPayRepo.reservation(paymentHash) }.thenReturn( + Result.success(QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15")), + ) + + emitNodeEvent( + Event.PaymentSuccessful( + paymentId = "payment_id", + paymentHash = paymentHash, + paymentPreimage = "preimage", + feePaidMsat = 10uL, + ), + ) + advanceUntilIdle() + + verify(quickPayRepo).clear(paymentHash) + verify(pendingPaymentRepo, never()).resolve(any()) + } + @Test fun `pending confirm lightning success keeps invoice amount`() = test { val paymentHash = "pending_confirm_hash" diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index b59f107d0..ea2cbe0f6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.setMain import org.junit.Before import org.junit.Test @@ -38,6 +39,7 @@ import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -56,7 +58,7 @@ class QuickPayViewModelTest : BaseUnitTest() { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) + nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) whenever(context.getString(any())).thenReturn("error") whenever(context.getString(R.string.wallet__send_quickpay__currency_conversion)).thenReturn("conversion") whenever(lightningRepo.lightningState).thenReturn( @@ -173,6 +175,8 @@ class QuickPayViewModelTest : BaseUnitTest() { } launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } + runCurrent() + assertTrue(nodeEvents.subscriptionCount.value > 0) nodeEvents.emit( Event.PaymentFailed( paymentId = "pid", From 68cba13dff0b5ddba0428617ed9ff795aa871bb2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:46:04 +0200 Subject: [PATCH 24/29] test: drop unshipped sat spend decode --- app/src/test/java/to/bitkit/data/CacheStoreTest.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index c7dd47f2a..915f35d3b 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -11,7 +11,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import to.bitkit.di.json import to.bitkit.ext.scopedActivityId import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -92,14 +91,4 @@ class CacheStoreTest : BaseUnitTest() { sut.data.first().deletedActivities, ) } - - @Test - fun `old sat spend field is not read as cents`() { - val data = json.decodeFromString( - """{"quickPaySpendDayKey":"2026-08-15","quickPaySpentSatsToday":20000}""", - ) - - assertEquals(0L, data.quickPaySpentCentsToday) - assertEquals("2026-08-15", data.quickPaySpendDayKey) - } } From ef74df45891139290f09fbb5ec203a7dc20e2cc4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:50:21 +0200 Subject: [PATCH 25/29] test: drop duplicate setmain in quickpay --- .../test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index ea2cbe0f6..f17628e7d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -2,7 +2,6 @@ package to.bitkit.viewmodels import android.content.Context import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -10,7 +9,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.setMain import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -57,7 +55,6 @@ class QuickPayViewModelTest : BaseUnitTest() { @Before fun setUp() { - Dispatchers.setMain(testDispatcher) nodeEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) whenever(context.getString(any())).thenReturn("error") whenever(context.getString(R.string.wallet__send_quickpay__currency_conversion)).thenReturn("conversion") From 5c21813dcddf559c5516b77a53654fbdb0e7b6e6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:55:45 +0200 Subject: [PATCH 26/29] refactor: inline quickpay day key --- app/src/main/java/to/bitkit/ext/DateTime.kt | 6 ------ app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt | 6 ++++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 145b2908d..8358e3871 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -39,12 +39,6 @@ fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMillisecon @OptIn(ExperimentalTime::class) fun Clock.nowMs(): Long = now().toEpochMilliseconds() -@OptIn(ExperimentalTime::class) -fun quickPaySpendDayKey( - clock: Clock = Clock.System, - timeZone: TimeZone = TimeZone.currentSystemDefault(), -): String = clock.now().toLocalDateTime(timeZone).date.toString() - fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun dateTimeFormatterOf( diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 8802ddbba..0974d51a3 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -3,13 +3,14 @@ package to.bitkit.repositories import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.QuickPayDaySpend import to.bitkit.data.QuickPaySpendReservation import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher -import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.runSuspendCatching import to.bitkit.models.USD import to.bitkit.utils.AppError @@ -147,7 +148,8 @@ class QuickPayRepo @Inject constructor( } } - private fun currentDayKey(): String = quickPaySpendDayKey(clock) + private fun currentDayKey(): String = + clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() } private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = From b0f56d577a21692e73eb67cca368a0bd41481190 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 01:00:34 +0200 Subject: [PATCH 27/29] refactor: move quickpay types to repo --- app/src/main/java/to/bitkit/data/CacheStore.kt | 12 +----------- .../java/to/bitkit/repositories/QuickPayRepo.kt | 14 ++++++++++++-- .../java/to/bitkit/viewmodels/QuickPayViewModel.kt | 2 +- .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- .../to/bitkit/viewmodels/QuickPayViewModelTest.kt | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 6b1b348de..7332bf4cb 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -17,6 +17,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.FxRate import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.WalletScope +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton @@ -178,14 +179,3 @@ data class AppCacheData( fun invalidateReceiveOnchainAddress() = copy(bip21 = "", onchainAddress = "") } - -@Serializable -data class QuickPaySpendReservation( - val amountCents: Long, - val dayKey: String, -) - -data class QuickPayDaySpend( - val dayKey: String, - val spentCents: Long, -) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 0974d51a3..6caa76b85 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -5,10 +5,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.Serializable import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore -import to.bitkit.data.QuickPayDaySpend -import to.bitkit.data.QuickPaySpendReservation import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching @@ -152,6 +151,17 @@ class QuickPayRepo @Inject constructor( clock.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() } +@Serializable +data class QuickPaySpendReservation( + val amountCents: Long, + val dayKey: String, +) + +private data class QuickPayDaySpend( + val dayKey: String, + val spentCents: Long, +) + private fun quickPayCapCents(thresholdUsd: Int, multiplier: Int): Long = thresholdUsd.toLong() * 100L * multiplier.toLong() diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index c2195faca..336f49e43 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -16,7 +16,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import to.bitkit.R -import to.bitkit.data.QuickPaySpendReservation +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.supportPaymentRequest diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 45d181977..48757b6f5 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -57,7 +57,7 @@ import to.bitkit.CurrentActivity import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore -import to.bitkit.data.QuickPaySpendReservation +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index f17628e7d..fe0abf224 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -26,7 +26,7 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.R -import to.bitkit.data.QuickPaySpendReservation +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState From d46ab0554c56d2a5db9dc3e1f6ab2dadd20d6623 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 01:12:22 +0200 Subject: [PATCH 28/29] refactor: drop experimentaltime opt-in --- app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt | 2 -- app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt | 3 --- 2 files changed, 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt index 6caa76b85..3b250c442 100644 --- a/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -17,9 +17,7 @@ import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock -import kotlin.time.ExperimentalTime -@OptIn(ExperimentalTime::class) @Singleton class QuickPayRepo @Inject constructor( private val cacheStore: CacheStore, diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index 467ebf2ba..e485b30d4 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -29,10 +29,8 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock -import kotlin.time.ExperimentalTime import kotlin.time.Instant -@OptIn(ExperimentalTime::class) @Config(application = Application::class, sdk = [34]) @RunWith(RobolectricTestRunner::class) class QuickPayRepoTest : BaseUnitTest() { @@ -211,7 +209,6 @@ class QuickPayRepoTest : BaseUnitTest() { } } -@OptIn(ExperimentalTime::class) private class MutableClock(var instant: Instant) : Clock { override fun now(): Instant = instant } From 0f59bc6a16e128986779e130a67cb6d74870792d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 01:16:38 +0200 Subject: [PATCH 29/29] chore: fix quickpay import order --- app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt | 2 +- .../test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 2 +- app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 336f49e43..ce2733e88 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -16,7 +16,6 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import to.bitkit.R -import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.supportPaymentRequest @@ -31,6 +30,7 @@ import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.QuickPayConversionError import to.bitkit.repositories.QuickPayRepo +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 48757b6f5..f75933a79 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -57,7 +57,6 @@ import to.bitkit.CurrentActivity import to.bitkit.R import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore -import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain @@ -99,6 +98,7 @@ import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.QuickPayRepo +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress import to.bitkit.repositories.SettledReceiveInvoice diff --git a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt index fe0abf224..7fbe78035 100644 --- a/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -26,13 +26,13 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.R -import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.QuickPayConversionError import to.bitkit.repositories.QuickPayRepo +import to.bitkit.repositories.QuickPaySpendReservation import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertIs