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 1a179f2b0e..cb3457e756 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 dc337a825f..e6645c2071 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,6 +139,43 @@ 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 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) { + 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 +201,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 5904e48815..eddec111d1 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 8358e3871f..145b2908d1 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/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 8c6d124055..851ee73128 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -21,8 +21,10 @@ 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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,10 +33,19 @@ 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.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 import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList @@ -52,201 +63,280 @@ 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 -fun StepSlider( +fun Slider( value: Int, steps: ImmutableList, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, + formatLabel: (Int) -> String = { "$$it" }, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - - var sliderWidth by remember { mutableIntStateOf(0) } 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() } - // Calculate step positions (evenly spaced) - val stepPositions = remember(steps, sliderWidth) { - if (sliderWidth == 0) { + val compositionStepPositions = remember(steps, layoutWidthPx) { + val sliderWidth = layoutWidthPx.toFloat() + if (sliderWidth <= 0f) { emptyList() } else { - steps.indices.map { index -> - val numSteps = (steps.size - 1).coerceAtLeast(1) - (index.toFloat() / numSteps) * sliderWidth - } + 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 } + val settledXState = rememberUpdatedState(settledX) - // Initialize knob position when value changes - LaunchedEffect(value, stepPositions) { - if (stepPositions.isNotEmpty()) { - val valueIndex = steps.indexOf(value) - if (valueIndex >= 0) { - knobPosition.snapTo(stepPositions[valueIndex]) - } + LaunchedEffect(settledX, isDragging) { + if (!isDragging) { + knobPosition.snapTo(settledX) } } - // Find closest step position - 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]) - - stepPositions.forEachIndexed { index, position -> - val distance = abs(currentPosition - position) - if (distance < minDistance) { - minDistance = distance - closestPosition = position - closestIndex = index - } - } - - return closestPosition to closestIndex - } - - Box( + SubcomposeLayout( modifier = modifier .fillMaxWidth() - .onGloballyPositioned { coordinates -> - 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), - ) - } - onValueChange(steps[closestIndex]) - } - } - ) { - 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), + .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() + val stepPositions = if (sliderWidth <= 0f) { + emptyList() + } else { + val numSteps = (steps.size - 1).coerceAtLeast(1) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } + } + val knobX = if (isDragging) knobPosition.value else stepPositions.getOrElse(valueIndex) { 0f } - // 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), - ) - } + fun findClosestStep(currentPosition: Float): Pair { + if (stepPositions.isEmpty()) return 0f to 0 - // 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() } + var closestPosition = stepPositions[0] + var closestIndex = 0 + var minDistance = abs(currentPosition - stepPositions[0]) - stepPositions.forEach { position -> - drawRoundRect( - color = Colors.White, - topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), - size = Size(markerWidth, markerHeight), - cornerRadius = CornerRadius(markerRadius), - ) + stepPositions.forEachIndexed { index, position -> + val distance = abs(currentPosition - position) + if (distance < minDistance) { + minDistance = distance + closestPosition = position + closestIndex = index + } } + + return closestPosition to closestIndex } - // 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), - ) + val trackPlaceable = subcompose(StepSliderSlot.Track) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + .pointerInput(stepPositions, steps) { + detectTapGestures { offset -> + val (closestStep, closestIndex) = findClosestStep(offset.x) + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = true + knobPosition.animateTo( + targetValue = closestStep, + animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), + ) + isDragging = false + } + onValueChange(steps[closestIndex]) } - onValueChange(steps[closestIndex]) - }, - ) { _, dragAmount -> - coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) - knobPosition.snapTo(newPosition) } + ) { + val trackY = center.y + val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } + val cornerRadius = density.run { 3.dp.toPx() } + + drawRoundRect( + color = Colors.Green32, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(size.width, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) + + if (knobX > 0f) { + drawRoundRect( + color = Colors.Green, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(knobX, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) + } + + 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() } + + 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) - ) + .offset { + IntOffset( + x = (knobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + y = 0, + ) + } + .size(KNOB_SIZE_DP.dp) + .pointerInput(stepPositions, steps, sliderWidth) { + detectDragGestures( + onDragStart = { + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = true + } + }, + onDragEnd = { + val (closestStep, closestIndex) = findClosestStep(knobPosition.value) + coroutineScope.launch { + knobPosition.animateTo( + targetValue = closestStep, + animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), + ) + isDragging = false + } + onValueChange(steps[closestIndex]) + }, + onDragCancel = { + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = false + } + }, + ) { _, dragAmount -> + coroutineScope.launch { + val newPosition = (knobPosition.value + dragAmount.x) + .coerceIn(0f, sliderWidth) + knobPosition.snapTo(newPosition) + } + } + } + ) { + Box( + modifier = Modifier + .size(KNOB_SIZE_DP.dp) + .clip(CircleShape) + .background(Colors.Green) + ) { + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(Colors.White) + .align(Alignment.Center) + ) + } + } } + }.first().measure(Constraints.fixed(width, knobHeightPx)) + + val labelsPlaceable = subcompose(StepSliderSlot.Labels) { + StepSliderLabels( + steps = steps, + formatLabel = formatLabel, + ) + }.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 } + +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 + } +} - // Step labels - steps.forEachIndexed { index, step -> - if (stepPositions.isNotEmpty() && index < stepPositions.size) { +@Composable +private fun StepSliderLabels( + steps: ImmutableList, + formatLabel: (Int) -> String, + modifier: Modifier = Modifier, +) { + Layout( + modifier = modifier, + content = { + steps.forEach { step -> Caption13Up( - text = "$$step", + text = formatLabel(step), color = Colors.White64, textAlign = TextAlign.Center, - 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(), - ) - } + 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) + } } } } /** - * 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 @@ -367,7 +457,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 }, @@ -378,7 +468,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)) { @@ -394,13 +484,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×" }, ) } } 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 145bea29e1..ea30a78d99 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,10 +2,11 @@ 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 +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 @@ -22,7 +23,8 @@ 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.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -38,12 +40,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 +57,16 @@ 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 + val multiplierFormat = stringResource(R.string.settings__quickpay__settings__multiplier_format) ScreenColumn { AppTopBar( @@ -66,9 +76,11 @@ fun QuickPaySettingsScreenContent( ) Column( - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) ) { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) SettingsSwitchRow( title = stringResource(R.string.settings__quickpay__settings__toggle), @@ -77,7 +89,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) @@ -85,23 +97,49 @@ 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) - StepSlider( + Slider( value = quickPayAmount, steps = sliderSteps, onValueChange = onQuickPayAmountChange, - modifier = Modifier.testTag("quickpay_amount_slider") + modifier = Modifier.testTag("QuickpayAmountSlider") ) - Spacer(modifier = Modifier.weight(1f)) + VerticalSpacer(32.dp) + + Caption13Up( + text = stringResource(R.string.settings__quickpay__settings__daily_label), + color = Colors.White64, + ) + + VerticalSpacer(16.dp) + + BodyM( + text = stringResource(R.string.settings__quickpay__settings__daily_text) + .replace("{limit}", dailyLimitUsd.toString()) + .replace("{multiplier}", quickPayDailyLimitMultiplier.toString()), + color = Colors.White64, + ) + + VerticalSpacer(16.dp) + + Slider( + value = quickPayDailyLimitMultiplier, + steps = dailyLimitSteps, + onValueChange = onQuickPayDailyLimitMultiplierChange, + formatLabel = { multiplierFormat.replace("{multiplier}", it.toString()) }, + modifier = Modifier.testTag("QuickpayDailyLimitSlider") + ) + + VerticalSpacer(32.dp) Image( painter = painterResource(R.drawable.fast_forward), contentDescription = null, @@ -109,14 +147,14 @@ fun QuickPaySettingsScreenContent( .fillMaxWidth() .height(256.dp) ) - Spacer(modifier = Modifier.weight(1f)) + VerticalSpacer(32.dp) BodyS( text = stringResource(R.string.settings__quickpay__settings__note), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } } @@ -128,6 +166,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 849726cc29..653c73fcdf 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 @@ -97,6 +98,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 @@ -122,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 @@ -2600,39 +2603,62 @@ class AppViewModel @Inject constructor( lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { - if (hasActiveContactPaymentContext()) return false + if (!canApplyQuickPay(amountSats)) return false - val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + Logger.info("Using QuickPay for '$amountSats' sats", context = TAG) - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() - ?: return false + val quickPayData: QuickPayData = when { + lnurlPay != null -> { + QuickPayData.LnurlPay( + sats = amountSats, + data = lnurlPay, + ) + } - if (amountSats <= quickPayAmountSats) { - Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold", 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) + private suspend fun canApplyQuickPay(amountSats: ULong): Boolean { + if (hasActiveContactPaymentContext()) return false - navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) - return true - } + 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 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 + + Logger.info( + "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + context = TAG, + ) return false } @@ -2677,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 && @@ -2710,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 0599d4ae42..3fb41e0aec 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -7,14 +7,23 @@ 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.R +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 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 import to.bitkit.repositories.PendingPaymentRepo @@ -27,6 +36,9 @@ 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, ) : ViewModel() { companion object { @@ -40,66 +52,115 @@ 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.paymentHash, prepared.displaySats, it.feePaidSats) } + .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") - _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) - _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 -> + setError(error) + return null } + 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) + } + + private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { + val dailyCapUsd = resolveDailyCapUsd() + if (dailyCapUsd == null) { + 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) + setError(QuickPayDailyLimitReachedError()) + } + return reserved + } + + 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.safe() + feePaidSats.safe()).toLong(), + ) + ) } } + 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) + 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? { + 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( bolt11: String, amount: ULong? = null, - ): Result { + ): Result { val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount) .onFailure { exception -> return Result.failure(exception) @@ -109,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))) ) @@ -121,6 +190,22 @@ class QuickPayViewModel @Inject constructor( } } +private data class PreparedQuickPay( + val bolt11: String, + val amount: ULong?, + val displaySats: ULong, + 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") + sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 9410f5c5f2..e406efd3e6 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 f0682904cb..a93e46970d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -933,7 +933,10 @@ 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 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 @@ -1237,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 diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 915f35d3bf..c595d7f47f 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -91,4 +91,44 @@ 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")) + } + + @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")) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6822141c60..1f7dc0d131 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 uses 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( @@ -2172,6 +2176,36 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), 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) + } + + @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" @@ -3179,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 new file mode 100644 index 0000000000..3a5339f9fa --- /dev/null +++ b/changelog.d/next/1159.security.md @@ -0,0 +1 @@ +QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead.