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..7332bf4cb1 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 @@ -164,6 +165,9 @@ data class AppCacheData( val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), + val quickPaySpendDayKey: String = "", + val quickPaySpentCentsToday: Long = 0L, + val quickPayReservations: Map = emptyMap(), ) { 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/models/Currency.kt b/app/src/main/java/to/bitkit/models/Currency.kt index 7925a03ed2..c74930db7e 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/repositories/PendingPaymentRepo.kt b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt index 349e9dc957..b2ac5e0387 100644 --- a/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PendingPaymentRepo.kt @@ -48,7 +48,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, val reason: PaymentFailureReason? = null, 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 0000000000..3b250c4422 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/QuickPayRepo.kt @@ -0,0 +1,175 @@ +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 kotlinx.serialization.Serializable +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore +import to.bitkit.di.IoDispatcher +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 +import kotlin.time.Clock + +@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 = 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() + 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 = + 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() + +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) + else -> QuickPayDaySpend(quickPaySpendDayKey, quickPaySpentCentsToday) +} 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..33f797d9b4 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 @@ -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,11 +33,22 @@ 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.platform.LocalLayoutDirection +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.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -47,206 +60,301 @@ 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 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 isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl 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 } + val visualKnobX = sliderLogicalToVisual(knobX, sliderWidth, isRtl) - // 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, isRtl, sliderWidth) { + detectTapGestures { offset -> + val logicalX = sliderPointerToLogical(offset.x, sliderWidth, isRtl) + val (closestStep, closestIndex) = findClosestStep(logicalX) + 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) { + val activeLeft = if (isRtl) visualKnobX else 0f + val activeWidth = if (isRtl) size.width - visualKnobX else visualKnobX + drawRoundRect( + color = Colors.Green, + topLeft = Offset(activeLeft, trackY - trackHeight / 2), + size = Size(activeWidth, 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 -> + val visualX = sliderLogicalToVisual(position, size.width, isRtl) + drawRoundRect( + color = Colors.White, + topLeft = Offset(visualX - 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 = (visualKnobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + y = 0, + ) + } + .size(KNOB_SIZE_DP.dp) + .pointerInput(stepPositions, steps, sliderWidth, isRtl) { + detectHorizontalDragGestures( + 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 + sliderDragDeltaToLogical(dragAmount, isRtl) + ).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) } + } +} - // Step labels - steps.forEachIndexed { index, step -> - if (stepPositions.isNotEmpty() && index < stepPositions.size) { +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, + 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 @@ -258,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) } @@ -266,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) } @@ -279,6 +388,9 @@ fun AmountSlider( } } + val widthPx = sliderWidth.toFloat() + val visualKnobX = sliderLogicalToVisual(knobPosition.value, widthPx, isRtl) + Box( modifier = modifier .fillMaxWidth() @@ -291,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) } } @@ -311,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), ) @@ -327,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)) } @@ -367,7 +482,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 +493,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 +509,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/screens/wallets/send/SendPendingScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt index 24b631d3ec..012164adc2 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: (PendingPaymentResolution.Failure) -> 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(resolution) } viewModel.onResolutionHandled() 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 5f03bbe4c4..75ae2ea492 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/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/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index c5d3c95d05..15753d15a7 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,12 @@ fun SendSheet( popUpTo(startDestination) { inclusive = true } } }, + onFallBackToConfirm = { + appViewModel.resetQuickPay() + navController.navigateTo(SendRoute.Confirm) { + popUpTo { inclusive = true } + } + }, onShowError = { failure -> appViewModel.clearActiveContactPaymentContext() navController.navigateTo( @@ -368,13 +374,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 4b71ee0f05..a7adba0e7e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -125,6 +125,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 @@ -156,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 @@ -220,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, @@ -1158,6 +1161,7 @@ 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) pendingPaymentRepo.resolve(PendingPaymentResolution.Failure(paymentHash, event.reason)) @@ -1172,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 @@ -1234,9 +1239,26 @@ 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) - pendingPaymentRepo.resolve(PendingPaymentResolution.Success(paymentHash)) + 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, + amountWithFeeSats = amountWithFeeSats, + ), + ) if (_currentSheet.value !is Sheet.Send || !pendingPaymentRepo.isActive(paymentHash)) { notifyPendingPaymentSucceeded() } @@ -2644,40 +2666,55 @@ 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) - } + 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, + ) + } + } - _quickPayData.update { quickPayData } - - Logger.debug("QuickPayData: $quickPayData", context = TAG) + Logger.debug("QuickPayData: $quickPayData", context = TAG) - navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) - return true - } + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) + return true + } - return false + private suspend fun canApplyQuickPay(amountSats: ULong): Boolean { + if (hasActiveContactPaymentContext()) return false + return quickPayRepo.canApply(amountSats).getOrDefault(false) } private fun resetAmountInput() { @@ -2721,7 +2758,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 && @@ -2754,7 +2791,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 54b2f92e61..ce2733e880 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 @@ -12,15 +15,22 @@ 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.ext.WatchResult import to.bitkit.ext.callbackAmountMsats 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.msatFloorOf +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.repositories.QuickPaySpendReservation import to.bitkit.utils.AppError import to.bitkit.utils.Logger import javax.inject.Inject @@ -30,6 +40,7 @@ class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, + private val quickPayRepo: QuickPayRepo, ) : ViewModel() { companion object { @@ -40,42 +51,93 @@ class QuickPayViewModel @Inject constructor( val uiState = _uiState.asStateFlow() val lightningState = lightningRepo.lightningState + private var payJob: Job? = null fun pay(data: QuickPayData) { - viewModelScope.launch { - val invoice = resolveQuickPayInvoice(data) ?: return@launch - - sendLightning(invoice.bolt11, invoice.amount) - .onSuccess { paymentHash -> - Logger.info("QuickPay lightning payment successful") - _uiState.update { - it.copy( - result = QuickPayResult.Success( - paymentHash = paymentHash, - amountWithFee = invoice.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 = invoice.displaySats.toLong(), - paymentRequest = invoice.paymentRequest, - ) - ) - } - return@onFailure - } - Logger.error("QuickPay lightning payment failed", error, context = TAG) + if (payJob?.isActive == true || _uiState.value.result != null) return + payJob = viewModelScope.launch { payNow(data) } + } - handleQuickPayFailure(error, invoice) - } + internal suspend fun payNow(data: QuickPayData) { + val invoice = resolveQuickPayInvoice(data) ?: return + val reservation = reserveSpend(invoice.displaySats) ?: return + + sendLightning(invoice, reservation) + .onSuccess { onPaymentSuccess(it.paymentHash, invoice.displaySats, it.feePaidSats) } + .onFailure { onPaymentFailure(it, invoice, reservation) } + } + + private suspend fun reserveSpend(amountSats: ULong): QuickPaySpendReservation? { + val reserved = quickPayRepo.tryReserve(amountSats).getOrElse { + setError(it) + return null + } + 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 reserved + } + + private suspend fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { + Logger.info("QuickPay lightning payment successful", context = TAG) + quickPayRepo.clear(paymentHash) + _uiState.update { + it.copy( + result = QuickPayResult.Success( + paymentHash = paymentHash, + amountWithFee = (displaySats.safe() + feePaidSats.safe()).toLong(), + ) + ) + } + } + + private suspend fun onPaymentFailure( + error: Throwable, + invoice: QuickPayInvoice, + reservation: QuickPaySpendReservation, + ) { + if (error is PaymentPendingException) { + Logger.info("QuickPay lightning payment pending", context = TAG) + _uiState.update { + it.copy( + result = QuickPayResult.Pending( + paymentHash = error.paymentHash, + amount = invoice.displaySats.toLong(), + paymentRequest = invoice.paymentRequest, + ) + ) + } + return + } + Logger.error("QuickPay lightning payment failed", error, context = TAG) + if (error is QuickPayPaymentFailedError) { + quickPayRepo.release(error.paymentHash) + } else { + quickPayRepo.releaseUnbound(reservation) } + handleQuickPayFailure(error, invoice) + } + + private fun setError(error: Throwable, paymentRequest: String? = null) { + val localizedMessage = when (error) { + is QuickPayConversionError -> { + context.getString(R.string.wallet__send_quickpay__currency_conversion) + } + else -> null + } + val failure = if (localizedMessage != null) { + SendFailureDetails( + message = localizedMessage, + failureType = error.toCompactFailureType(), + resetRoutingCachesOnRetry = false, + paymentRequest = paymentRequest, + ) + } else { + error.toSendFailureDetails(context, paymentRequest) + } + _uiState.update { it.copy(result = QuickPayResult.Error(failure)) } } private suspend fun resolveQuickPayInvoice(data: QuickPayData): QuickPayInvoice? { @@ -118,32 +180,56 @@ class QuickPayViewModel @Inject constructor( } private suspend fun sendLightning( - bolt11: String, - amount: ULong? = null, - ): Result { - val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount) + invoice: QuickPayInvoice, + reservation: QuickPaySpendReservation, + ): Result { + val hash = lightningRepo.payInvoice(bolt11 = invoice.bolt11, sats = invoice.amount) .onFailure { exception -> return Result.failure(exception) } .getOrDefault("") - // 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.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( - Result.failure( - QuickPayPaymentFailedError(reason = it.reason, paymentRequest = bolt11) - ) - ) + 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, + ) + ) + ) - 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)) } - return result ?: Result.failure(PaymentPendingException(hash)) } } +private data class SettledQuickPayPayment( + val paymentHash: PaymentId, + val feePaidSats: ULong, +) + sealed class QuickPayResult { data class Success( val paymentHash: String, @@ -156,6 +242,8 @@ sealed class QuickPayResult { val paymentRequest: String, ) : QuickPayResult() + data object FallBackToConfirm : QuickPayResult() + data class Error(val failure: SendFailureDetails) : QuickPayResult() } @@ -173,6 +261,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/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 357583ed8f..57a288533a 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 @@ -1249,6 +1252,7 @@ Reserve Balance This payment is taking a bit longer than expected. You can continue using Bitkit. Payment Pending + Currency conversion failed 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 new file mode 100644 index 0000000000..e485b30d46 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -0,0 +1,214 @@ +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.Instant + +@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()) + } + + @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) + } +} + +private class MutableClock(var instant: Instant) : Clock { + override fun now(): Instant = instant +} 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 0000000000..0e19a67bea --- /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 e58a089b26..f75933a793 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 @@ -95,6 +97,8 @@ 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.QuickPaySpendReservation import to.bitkit.repositories.SamRockRepo import to.bitkit.repositories.SettledReceiveAddress import to.bitkit.repositories.SettledReceiveInvoice @@ -156,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() @@ -224,6 +229,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + 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)) + 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)) @@ -322,6 +333,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { notifyPaymentReceivedHandler = notifyPaymentReceivedHandler, notifyChannelReadyHandler = notifyChannelReadyHandler, cacheStore = cacheStore, + quickPayRepo = quickPayRepo, transferRepo = transferRepo, migrationService = migrationService, coreService = coreService, @@ -1716,6 +1728,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).resolve(PendingPaymentResolution.Success(paymentHash)) verify(activityRepo).setContact(contactPublicKey = contactKey, forPaymentId = paymentHash) + verify(quickPayRepo).clear(paymentHash) } @Test @@ -1741,9 +1754,108 @@ class AppViewModelSendFlowTest : BaseUnitTest() { reason = PaymentFailureReason.RETRIES_EXHAUSTED, ) ) + verify(quickPayRepo).release(paymentHash) 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" + whenever(pendingPaymentRepo.isPending(paymentHash)).thenReturn(true) + whenever(pendingPaymentRepo.isActive(paymentHash)).thenReturn(false) + whenever { quickPayRepo.reservation(paymentHash) }.thenReturn(Result.success(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 { quickPayRepo.reservation(paymentHash) }.thenReturn( + Result.success(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(quickPayRepo).clear(paymentHash) + } + @Test fun `active lightning send failure navigates to failure screen`() = test { val bolt11 = "lnbcrt1activefailure" @@ -1786,6 +1898,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") @@ -2163,7 +2330,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() @@ -2171,30 +2338,30 @@ 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) } @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" - enableQuickPay(thresholdSats = 1000u) + enableQuickPay() stubLightningScan(bolt11 = bolt11, amountSats = 500u) sut.onScanResult(bolt11) 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) } @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) + enableQuickPay() settingsData.value = settingsData.value.copy( isPinEnabled = true, isPinForPaymentsEnabled = true, @@ -2209,10 +2376,39 @@ 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() + 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(canApply = false) + 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" - enableQuickPay(thresholdSats = 1_000u) + enableQuickPay() settingsData.value = settingsData.value.copy( isPinEnabled = true, isPinForPaymentsEnabled = true, @@ -2505,7 +2701,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") @@ -3216,9 +3412,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - private fun enableQuickPay(thresholdSats: ULong) { + private fun enableQuickPay(canApply: Boolean = true) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) - whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) + whenever { quickPayRepo.canApply(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 new file mode 100644 index 0000000000..7fbe780356 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/QuickPayViewModelTest.kt @@ -0,0 +1,241 @@ +package to.bitkit.viewmodels + +import android.content.Context +import kotlinx.coroutines.CompletableDeferred +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.runCurrent +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.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 +import org.robolectric.annotation.Config +import to.bitkit.R +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 +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@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 quickPayRepo: QuickPayRepo = mock() + + private lateinit var nodeEvents: MutableSharedFlow + private val reserved = QuickPaySpendReservation(amountCents = 250L, dayKey = "2026-08-15") + + private lateinit var sut: QuickPayViewModel + + @Before + fun setUp() { + 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( + MutableStateFlow(LightningState(nodeLifecycleState = NodeLifecycleState.Running)), + ) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + 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, + quickPayRepo = quickPayRepo, + ) + } + + @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", + paymentHash = "hash1", + paymentPreimage = "preimage", + feePaidMsat = 1_000uL, + ), + ) + advanceUntilIdle() + + val order = inOrder(quickPayRepo, lightningRepo) + order.verify(quickPayRepo).tryReserve(500u) + order.verify(lightningRepo).payInvoice(bolt11 = "lnbcrt1test", sats = null) + 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) + 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(quickPayRepo, pendingPaymentRepo) + order.verify(quickPayRepo).remember("hash1", reserved) + 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(quickPayRepo).releaseUnbound(reserved) + verify(quickPayRepo, never()).remember(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")) + + launch { sut.payNow(QuickPayData.Bolt11(sats = 500u, bolt11 = "lnbcrt1test")) } + nodeEvents.emit( + Event.PaymentFailed( + paymentId = "pid", + paymentHash = "hash1", + reason = PaymentFailureReason.ROUTE_NOT_FOUND, + ), + ) + advanceUntilIdle() + + verify(quickPayRepo).remember("hash1", reserved) + verify(quickPayRepo).release("hash1") + assertIs(sut.uiState.value.result) + } + + @Test + fun `reserve failure emits FallBackToConfirm`() = test { + whenever { quickPayRepo.tryReserve(any()) }.thenReturn(Result.success(null)) + + 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 }) + } + + @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")) } + runCurrent() + assertTrue(nodeEvents.subscriptionCount.value > 0) + 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()) + } +} 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.