diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index fd70ca71e8..6cba9cb449 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Event import to.bitkit.App import to.bitkit.R @@ -107,10 +108,17 @@ class LightningNodeService : Service() { if (event !is Event.PaymentReceived && event !is Event.OnchainTransactionReceived) return val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return - notifyPaymentReceivedHandler(command).onSuccess { - Logger.debug("Payment notification result: $it", context = TAG) - if (it !is NotifyPaymentReceived.Result.ShowNotification) return - showPaymentNotification(it.sheet, it.notification) + notifyPaymentReceivedHandler(command).onSuccess { result -> + Logger.debug("Handled payment notification with result '$result'", context = TAG) + if (result !is NotifyPaymentReceived.Result.ShowNotification) return + val presented = withContext(uiDispatcher) { + if (!notifyPaymentReceivedHandler.claimPresentation(command) { App.currentActivity?.value == null }) { + return@withContext false + } + showPaymentNotification(result.sheet, result.notification) + true + } + if (presented) notifyPaymentReceivedHandler.recordPresentation(command) } } @@ -133,10 +141,6 @@ class LightningNodeService : Service() { sheet: NewTransactionSheetDetails, notification: NotificationDetails, ) { - if (App.currentActivity?.value != null) { - Logger.debug("Skipping payment notification: activity is active", context = TAG) - return - } Logger.debug("Showing payment notification: ${notification.title}", context = TAG) serviceScope.launch { cacheStore.setBackgroundReceive(sheet) } pushNotification(notification.title, notification.body) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 477d4f3c94..348122835f 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -55,14 +55,27 @@ class CacheStore @Inject constructor( store.updateData { it.copy(onchainAddress = address) } } - suspend fun saveBolt11(bolt11: String) { - store.updateData { it.copy(bolt11 = bolt11) } + suspend fun saveBolt11(bolt11: String, paymentHash: String) { + store.updateData { it.copy(bolt11 = bolt11, bolt11PaymentHash = paymentHash) } } suspend fun setBip21(bip21: String) { store.updateData { it.copy(bip21 = bip21) } } + suspend fun invalidateReceiveLightningInvoice(expectedBolt11: String): Boolean { + var invalidated = false + store.updateData { + if (it.bolt11 == expectedBolt11) { + invalidated = true + it.invalidateReceiveLightningInvoice() + } else { + it + } + } + return invalidated + } + suspend fun cacheBalance(balanceState: BalanceState) { store.updateData { it.copy(balance = balanceState) } } @@ -123,6 +136,7 @@ data class AppCacheData( val paidOrders: Map = mapOf(), val onchainAddress: String = "", val bolt11: String = "", + val bolt11PaymentHash: String = "", val bip21: String = "", val balance: BalanceState? = null, val backupStatuses: Map = mapOf(), @@ -132,5 +146,7 @@ data class AppCacheData( val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), ) { - fun resetBip21() = copy(bip21 = "", bolt11 = "", onchainAddress = "") + fun resetBip21() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "", onchainAddress = "") + + fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "", bolt11PaymentHash = "") } diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 179b624e18..e3ea82a031 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType @@ -19,16 +20,21 @@ class NotifyPaymentReceivedHandler @Inject constructor( private val activityRepo: ActivityRepo, private val receivedNotificationContent: ReceivedNotificationContent, ) { + private val presentationClaimsLock = Any() + private val presentationClaims = mutableSetOf() + suspend operator fun invoke( command: NotifyPaymentReceived.Command, ): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { + if (isPresentationClaimed(command)) return@runSuspendCatching NotifyPaymentReceived.Result.Skip + val shouldShow = when (command) { is NotifyPaymentReceived.Command.Lightning -> shouldShowLightning(command) is NotifyPaymentReceived.Command.Onchain -> shouldShowOnchain(command) } - if (!shouldShow) return@runCatching NotifyPaymentReceived.Result.Skip + if (!shouldShow) return@runSuspendCatching NotifyPaymentReceived.Result.Skip val details = buildSheetDetails(command) @@ -43,12 +49,39 @@ class NotifyPaymentReceivedHandler @Inject constructor( } } + fun claimPresentation(command: NotifyPaymentReceived.Command): Boolean = claimPresentation(command) { true } + + fun claimPresentation( + command: NotifyPaymentReceived.Command, + canPresent: () -> Boolean, + ): Boolean { + val key = presentationKey(command) ?: return false + return synchronized(presentationClaimsLock) { + canPresent() && presentationClaims.add(key) + } + } + + suspend fun recordPresentation(command: NotifyPaymentReceived.Command) { + withContext(ioDispatcher) { + runSuspendCatching { markAsSeen(command) } + .onFailure { Logger.error("Failed to mark payment notification as presented", it, context = TAG) } + } + } + + private fun isPresentationClaimed(command: NotifyPaymentReceived.Command): Boolean { + val key = presentationKey(command) ?: return false + return synchronized(presentationClaimsLock) { key in presentationClaims } + } + + private fun presentationKey(command: NotifyPaymentReceived.Command): String? = when (command) { + is NotifyPaymentReceived.Command.Lightning -> command.event.paymentId?.let { "lightning:$it" } + is NotifyPaymentReceived.Command.Onchain -> "onchain:${command.event.txid}" + } + private suspend fun shouldShowLightning(command: NotifyPaymentReceived.Command.Lightning): Boolean { val paymentId = command.event.paymentId ?: return false delay(DELAY_FOR_ACTIVITY_SYNC_MS) - if (activityRepo.isActivitySeen(paymentId)) return false - activityRepo.markActivityAsSeen(paymentId) - return true + return !activityRepo.isActivitySeen(paymentId) } private suspend fun shouldShowOnchain(command: NotifyPaymentReceived.Command.Onchain): Boolean { @@ -60,12 +93,20 @@ class NotifyPaymentReceivedHandler @Inject constructor( command.event.txid, command.event.details.amountSats.toULong(), ) - if (shouldShowSheet) { - activityRepo.markOnchainActivityAsSeen(command.event.txid) - } return shouldShowSheet } + private suspend fun markAsSeen(command: NotifyPaymentReceived.Command) { + when (command) { + is NotifyPaymentReceived.Command.Lightning -> { + val paymentId = command.event.paymentId ?: return + activityRepo.markActivityAsSeen(paymentId) + } + + is NotifyPaymentReceived.Command.Onchain -> activityRepo.markOnchainActivityAsSeen(command.event.txid) + } + } + private suspend fun retryShouldShowReceivedSheet(txid: String, amountSats: ULong): Boolean { repeat(MAX_RETRIES) { if (activityRepo.shouldShowReceivedSheet(txid, amountSats)) return true diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1e8800355c..0599d01ddc 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BestBlock +import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.ChannelConfig import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails @@ -65,6 +66,7 @@ import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS @@ -73,6 +75,7 @@ import to.bitkit.models.NATIVE_WITNESS_TYPES import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed +import to.bitkit.models.WalletScope import to.bitkit.models.safe import to.bitkit.models.satsToMsat import to.bitkit.models.toAddressType @@ -88,7 +91,6 @@ import to.bitkit.services.LnurlWithdrawResponse import to.bitkit.services.LspNotificationsService import to.bitkit.services.NodeEventHandler import to.bitkit.utils.AppError -import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.UrlValidator @@ -125,8 +127,9 @@ class LightningRepo @Inject constructor( private val _lightningState = MutableStateFlow(LightningState()) val lightningState = _lightningState.asStateFlow() - private val _nodeEvents = MutableSharedFlow(extraBufferCapacity = 64) - val nodeEvents = _nodeEvents.asSharedFlow() + private val _nodeEventUpdates = MutableSharedFlow(extraBufferCapacity = 64) + val nodeEventUpdates = _nodeEventUpdates.asSharedFlow() + val nodeEvents = nodeEventUpdates.map { it.event } private val scope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -439,10 +442,55 @@ class LightningRepo @Inject constructor( private suspend fun onEvent(event: Event) { handleLdkEvent(event) recordProbeOutcome(event) + val settledReceiveInvoice = if (event is Event.PaymentReceived) { + val settledInvoice = invalidateSettledReceiveInvoice(event) + val paymentId = event.paymentId ?: event.paymentHash + runSuspendCatching { coreService.activity.handlePaymentEvent(paymentId) } + .onFailure { Logger.error("Failed to record received payment '$paymentId'", it, context = TAG) } + settledInvoice + } else { + null + } _eventHandlers.toList().forEach { runCatching { it.invoke(event) } } - _nodeEvents.emit(event) + _nodeEventUpdates.emit( + NodeEventUpdate( + event = event, + settledReceiveInvoice = settledReceiveInvoice, + ), + ) + } + + private suspend fun invalidateSettledReceiveInvoice(event: Event.PaymentReceived): SettledReceiveInvoice? { + val cachedReceive = runSuspendCatching { + cacheStore.data.first() + }.onFailure { + Logger.error("Failed to read cached receive invoice", it, context = TAG) + }.getOrNull() ?: return null + val cachedBolt11 = cachedReceive.bolt11 + if (cachedBolt11.isEmpty()) return null + + val cachedPaymentHash = cachedReceive.bolt11PaymentHash.takeIf { it.isNotEmpty() } ?: runCatching { + Bolt11Invoice.fromStr(cachedBolt11).paymentHash() + }.onFailure { + Logger.error("Failed to parse cached receive invoice", it, context = TAG) + }.getOrNull() + if (cachedPaymentHash != event.paymentHash) return null + + val invalidated = runSuspendCatching { + cacheStore.invalidateReceiveLightningInvoice(expectedBolt11 = cachedBolt11) + }.onFailure { + Logger.error("Failed to invalidate settled receive invoice", it, context = TAG) + }.getOrDefault(false) + + return if (invalidated) { + SettledReceiveInvoice( + bolt11 = cachedBolt11, + ) + } else { + null + } } fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled } @@ -1729,6 +1777,16 @@ class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node t class GetPaymentsError : AppError("It wasn't possible get the payments") class SyncUnhealthyError : AppError("Wallet sync failed before send") class LnurlPayInvoiceMismatchError : AppError("The invoice did not match the requested payment. Payment cancelled.") + +data class NodeEventUpdate( + val event: Event, + val settledReceiveInvoice: SettledReceiveInvoice? = null, +) + +data class SettledReceiveInvoice( + val bolt11: String, +) + sealed class ProbeError(message: String) : AppError(message) { class NoProbeHandles : ProbeError("No probe handles returned") class TimedOut : ProbeError("Probe timed out") diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 681f7bd74f..87745fae9f 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.WordCount import to.bitkit.data.CacheStore @@ -36,6 +37,7 @@ import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.WalletScope import to.bitkit.models.msatFloorOf import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toBalance @@ -45,7 +47,6 @@ import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.Bip21Utils -import to.bitkit.models.WalletScope import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError import to.bitkit.utils.measured @@ -83,9 +84,12 @@ class WalletRepo @Inject constructor( init { repoScope.launch { - lightningRepo.nodeEvents.collect { event -> + lightningRepo.nodeEventUpdates.collect { update -> if (!walletExists()) return@collect - refreshBip21ForEvent(event) + refreshBip21ForEvent( + event = update.event, + settledReceiveInvoice = update.settledReceiveInvoice, + ) } } repoScope.launch { @@ -309,7 +313,10 @@ class WalletRepo @Inject constructor( eventSyncJob = null } - suspend fun refreshBip21ForEvent(event: Event) = withContext(bgDispatcher) { + suspend fun refreshBip21ForEvent( + event: Event, + settledReceiveInvoice: SettledReceiveInvoice? = null, + ) = withContext(bgDispatcher) { when (event) { is Event.ChannelReady -> { // Only refresh bolt11 if we can now receive on lightning @@ -334,8 +341,17 @@ class WalletRepo @Inject constructor( } } - is Event.PaymentReceived, is Event.OnchainTransactionReceived -> { - // Check if onchain address was used, generate new one if needed + is Event.PaymentReceived if settledReceiveInvoice != null -> { + val settledBolt11 = settledReceiveInvoice.bolt11 + if (!invalidateSettledReceiveInvoice(settledBolt11)) return@withContext + Logger.debug("Refreshing BIP21 for event '$event'", context = TAG) + updateBip21Url() + refreshAddressIfNeeded() + } + + is Event.PaymentReceived -> Unit + + is Event.OnchainTransactionReceived -> { Logger.debug("refreshBip21ForEvent: $event", context = TAG) refreshAddressIfNeeded() updateBip21Url() @@ -349,6 +365,14 @@ class WalletRepo @Inject constructor( refreshReusableReceiveAddress() } + private fun invalidateSettledReceiveInvoice(settledBolt11: String): Boolean { + while (true) { + val state = _walletState.value + if (state.bolt11 != settledBolt11) return false + if (_walletState.compareAndSet(state, state.copy(bolt11 = "", bip21 = ""))) return true + } + } + suspend fun refreshReusableReceiveAddress(): Result = withContext(bgDispatcher) { runSuspendCatching { refreshReusableReceiveAddressOrThrow() @@ -554,7 +578,14 @@ class WalletRepo @Inject constructor( fun getBolt11(): String = _walletState.value.bolt11 suspend fun setBolt11(bolt11: String) { - runCatching { cacheStore.saveBolt11(bolt11) } + val paymentHash = if (bolt11.isEmpty()) { + "" + } else { + runCatching { Bolt11Invoice.fromStr(bolt11).paymentHash() } + .onFailure { Logger.error("Failed to parse receive invoice", it, context = TAG) } + .getOrDefault("") + } + runSuspendCatching { cacheStore.saveBolt11(bolt11, paymentHash) } _walletState.update { it.copy(bolt11 = bolt11) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 317da179db..0b3680add1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -130,6 +130,7 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LnurlPayInvoiceMismatchError +import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentNotification import to.bitkit.repositories.PendingPaymentRepo @@ -261,6 +262,12 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() private var queuedPairingCodeRequestId: Long? = null + private var receiveSheetContext: ReceiveSheetContext? = null + + private data class ReceiveSheetContext( + val sheet: Sheet.Receive, + val bolt11: String, + ) private val processedPaymentsLock = Any() private val processedPayments = mutableSetOf() @@ -381,6 +388,7 @@ class AppViewModel @Inject constructor( } } } + observeReceiveSheetInvoice() observeLdkNodeEvents() observeLightningUsableChannels() observePublicPaykitEndpoints() @@ -418,7 +426,20 @@ class AppViewModel @Inject constructor( private fun observeLdkNodeEvents() { viewModelScope.launch { - lightningRepo.nodeEvents.collect { handleLdkEvent(it) } + lightningRepo.nodeEventUpdates.collect { handleLdkEvent(it) } + } + } + + private fun observeReceiveSheetInvoice() { + viewModelScope.launch { + walletRepo.walletState + .map { it.bolt11 } + .distinctUntilChanged() + .collect { bolt11 -> + if (bolt11.isEmpty()) return@collect + val receiveSheet = _currentSheet.value as? Sheet.Receive ?: return@collect + receiveSheetContext = ReceiveSheetContext(receiveSheet, bolt11) + } } } @@ -573,10 +594,17 @@ class AppViewModel @Inject constructor( } @Suppress("CyclomaticComplexMethod") - private fun handleLdkEvent(event: Event) { + private fun handleLdkEvent(update: NodeEventUpdate) { + val event = update.event if (!walletRepo.walletExists()) return Logger.debug("LDK-node event received in $TAG: ${jsonLogOf(event)}", context = TAG) + val receiveSheetToClose = receiveSheetContext?.takeIf { context -> + event is Event.PaymentReceived && + update.settledReceiveInvoice?.bolt11 == context.bolt11 && + _currentSheet.value === context.sheet + } + viewModelScope.launch { runCatching { when (event) { @@ -592,7 +620,7 @@ class AppViewModel @Inject constructor( is Event.PaymentClaimable -> Unit is Event.PaymentFailed -> handlePaymentFailed(event) is Event.PaymentForwarded -> Unit - is Event.PaymentReceived -> handlePaymentReceived(event) + is Event.PaymentReceived -> handlePaymentReceived(event, receiveSheetToClose) is Event.PaymentSuccessful -> handlePaymentSuccessful(event) is Event.ProbeFailed -> Unit is Event.ProbeSuccessful -> Unit @@ -912,9 +940,19 @@ class AppViewModel @Inject constructor( return true } - private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { + private suspend fun handlePaymentReceived( + event: Event.PaymentReceived, + receiveSheetToClose: ReceiveSheetContext?, + ) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + if ( + receiveSheetToClose != null && + receiveSheetContext === receiveSheetToClose && + _currentSheet.value === receiveSheetToClose.sheet + ) { + hideSheet() + } privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash)?.let { publicKey -> activityRepo.setContact( contactPublicKey = publicKey, @@ -992,7 +1030,9 @@ class AppViewModel @Inject constructor( val command = NotifyPaymentReceived.Command.from(event) ?: return val result = notifyPaymentReceivedHandler(command).getOrNull() if (result !is NotifyPaymentReceived.Result.ShowSheet) return + if (!notifyPaymentReceivedHandler.claimPresentation(command)) return showTransactionSheet(result.sheet) + notifyPaymentReceivedHandler.recordPresentation(command) } private fun notifyTransactionUnconfirmed() = toast( @@ -2824,16 +2864,21 @@ class AppViewModel @Inject constructor( fun showSheet(sheetType: Sheet) { viewModelScope.launch { + receiveSheetContext = null _currentSheet.value?.let { _currentSheet.update { null } delay(SCREEN_TRANSITION_DELAY) } + receiveSheetContext = (sheetType as? Sheet.Receive)?.let { receiveSheet -> + ReceiveSheetContext(receiveSheet, walletRepo.getBolt11()) + } _currentSheet.update { sheetType } } } fun hideSheet() { scanResultHandler = null + receiveSheetContext = null when { currentSheet.value is Sheet.TimedSheet -> { // Only dismiss if manager still has a sheet (user initiated) diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 27f09f40d2..c6a361fb2b 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -154,6 +154,9 @@ class LightningNodeServiceTest : BaseUnitTest() { ) whenever(notifyPaymentReceivedHandler.invoke(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification))) + whenever(notifyPaymentReceivedHandler.claimPresentation(any(), any())).thenAnswer { + it.getArgument<() -> Boolean>(1).invoke() + } // Default: not a CJIT channel ready unless a test overrides it whenever(notifyChannelReadyHandler.invoke(any())) @@ -306,10 +309,12 @@ class LightningNodeServiceTest : BaseUnitTest() { sats = 100L, ) verify(cacheStore).setBackgroundReceive(expected) + verify(notifyPaymentReceivedHandler).claimPresentation(any(), any()) + verify(notifyPaymentReceivedHandler).recordPresentation(any()) } @Test - fun `payment received in foreground does nothing`() = test { + fun `payment received in foreground is left for the UI`() = test { // Simulate foreground by setting App.currentActivity.value via lifecycle callback val mockActivity: Activity = mock() App.currentActivity?.onActivityStarted(mockActivity) @@ -337,6 +342,88 @@ class LightningNodeServiceTest : BaseUnitTest() { assertNull(paymentNotification, "Payment notification should NOT be present in foreground") verify(cacheStore, never()).setBackgroundReceive(any()) + verify(notifyPaymentReceivedHandler).invoke(any()) + verify(notifyPaymentReceivedHandler).claimPresentation(any(), any()) + verify(notifyPaymentReceivedHandler, never()).recordPresentation(any()) + } + + @Test + fun `payment received while app foregrounds is left for the UI`() = test { + val mockActivity: Activity = mock() + whenever(notifyPaymentReceivedHandler.invoke(any())).doSuspendableAnswer { + App.currentActivity?.onActivityStarted(mockActivity) + val sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "test_hash", + sats = 100L, + ) + val notification = NotificationDetails( + title = context.getString(R.string.notification__received__title), + body = "Received ₿ 100 ($0.10)", + ) + Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification)) + } + startService() + testScheduler.advanceUntilIdle() + + capturedHandler?.invoke( + Event.PaymentReceived( + paymentId = "payment_id", + paymentHash = "test_hash", + amountMsat = 100000u, + customRecords = emptyList(), + ), + ) + testScheduler.advanceUntilIdle() + + val notification = Shadows.shadowOf(context.notificationManager).allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) + } + assertNull(notification) + verify(notifyPaymentReceivedHandler).claimPresentation(any(), any()) + verify(notifyPaymentReceivedHandler, never()).recordPresentation(any()) + verify(cacheStore, never()).setBackgroundReceive(any()) + } + + @Test + fun `payment received while app backgrounds is presented by the service`() = test { + val mockActivity: Activity = mock() + App.currentActivity?.onActivityStarted(mockActivity) + whenever(notifyPaymentReceivedHandler.invoke(any())).doSuspendableAnswer { + App.currentActivity?.onActivityStopped(mockActivity) + val sheet = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "test_hash", + sats = 100L, + ) + val notification = NotificationDetails( + title = context.getString(R.string.notification__received__title), + body = "Received ₿ 100 ($0.10)", + ) + Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification)) + } + startService() + testScheduler.advanceUntilIdle() + + capturedHandler?.invoke( + Event.PaymentReceived( + paymentId = "payment_id", + paymentHash = "test_hash", + amountMsat = 100000u, + customRecords = emptyList(), + ), + ) + testScheduler.advanceUntilIdle() + + val notification = Shadows.shadowOf(context.notificationManager).allNotifications.find { + it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) + } + assertNotNull(notification) + verify(notifyPaymentReceivedHandler).claimPresentation(any(), any()) + verify(notifyPaymentReceivedHandler).recordPresentation(any()) + verify(cacheStore).setBackgroundReceive(any()) } @Test diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt new file mode 100644 index 0000000000..ed4671630d --- /dev/null +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -0,0 +1,50 @@ +package to.bitkit.data + +import org.junit.Test +import to.bitkit.data.serializers.AppCacheSerializer +import to.bitkit.models.BalanceState +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals + +class AppCacheDataTest : BaseUnitTest() { + companion object { + private const val LEGACY_BOLT11 = + "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs" + + "pp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq" + + "dpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq" + + "9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52" + + "vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql" + } + + @Test + fun `AppCacheSerializer reads a legacy receive invoice without a stored payment hash`() = test { + val cachedReceive = AppCacheSerializer.readFrom( + """{"bolt11":"$LEGACY_BOLT11","bip21":"bitcoin:bc1qtest?lightning=$LEGACY_BOLT11"}""" + .byteInputStream(), + ) + + assertEquals(LEGACY_BOLT11, cachedReceive.bolt11) + assertEquals("", cachedReceive.bolt11PaymentHash) + } + + @Test + fun `invalidateReceiveLightningInvoice clears the paid request and preserves other cache data`() { + val cache = AppCacheData( + onchainAddress = "bc1qtest", + bolt11 = "paidInvoice", + bolt11PaymentHash = "paymentHash", + bip21 = "bitcoin:bc1qtest?lightning=paidInvoice", + balance = BalanceState(totalOnchainSats = 42uL), + paidOrders = mapOf("order" to "txid"), + ) + + val invalidated = cache.invalidateReceiveLightningInvoice() + + assertEquals("", invalidated.bolt11) + assertEquals("", invalidated.bolt11PaymentHash) + assertEquals("", invalidated.bip21) + assertEquals(cache.onchainAddress, invalidated.onchainAddress) + assertEquals(cache.balance, invalidated.balance) + assertEquals(cache.paidOrders, invalidated.paidOrders) + } +} diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt new file mode 100644 index 0000000000..4c9a35daf4 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -0,0 +1,45 @@ +package to.bitkit.data + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +@Config(application = Application::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class CacheStoreTest : BaseUnitTest() { + private val context = ApplicationProvider.getApplicationContext() + private val sut = CacheStore(context) + + @Before + fun setUp() = runBlocking { sut.reset() } + + @After + fun tearDown() = runBlocking { sut.reset() } + + @Test + fun `invalidateReceiveLightningInvoice preserves a replacement invoice`() = test { + val replacement = AppCacheData( + onchainAddress = "bc1qtest", + bolt11 = "replacementInvoice", + bolt11PaymentHash = "replacementHash", + bip21 = "bitcoin:bc1qtest?lightning=replacementInvoice", + ) + sut.update { replacement } + + val invalidated = sut.invalidateReceiveLightningInvoice(expectedBolt11 = "settledInvoice") + + assertFalse(invalidated) + assertEquals(replacement, sut.data.first()) + } +} diff --git a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt index 1c935765f6..ca08e47356 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -25,6 +25,7 @@ import to.bitkit.repositories.CurrencyRepo import to.bitkit.test.BaseUnitTest import java.math.BigDecimal import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -85,6 +86,12 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertEquals(1000L, paymentResult.sheet.sats) + verify(activityRepo, never()).markActivityAsSeen(any()) + + val claimed = sut.claimPresentation(command) + + assertTrue(claimed) + sut.recordPresentation(command) verify(activityRepo).markActivityAsSeen("paymentId123") } @@ -110,6 +117,13 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertNotNull(paymentResult.notification) assertEquals("Payment Received", paymentResult.notification.title) + verify(activityRepo, never()).markActivityAsSeen(any()) + + val claimed = sut.claimPresentation(command) + + assertTrue(claimed) + sut.recordPresentation(command) + verify(activityRepo).markActivityAsSeen("paymentId123") } @Test @@ -133,6 +147,12 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(NewTransactionSheetDirection.RECEIVED, paymentResult.sheet.direction) assertEquals("txid456", paymentResult.sheet.paymentHashOrTxId) assertEquals(5000L, paymentResult.sheet.sats) + verify(activityRepo, never()).markOnchainActivityAsSeen(any()) + + val claimed = sut.claimPresentation(command) + + assertTrue(claimed) + sut.recordPresentation(command) verify(activityRepo).markOnchainActivityAsSeen("txid456") } @@ -168,6 +188,8 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val command = NotifyPaymentReceived.Command.Onchain(event = event) sut(command) + sut.claimPresentation(command) + sut.recordPresentation(command) inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) @@ -243,4 +265,47 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.Skip) } + + @Test + fun `recordPresentation keeps the claim when marking as seen fails`() = test { + val event = mock { + on { amountMsat } doReturn 1000000uL + on { paymentHash } doReturn "hash123" + on { paymentId } doReturn "paymentId123" + } + whenever(activityRepo.markActivityAsSeen("paymentId123")) + .thenThrow(IllegalStateException("activity store unavailable")) + val command = NotifyPaymentReceived.Command.Lightning(event = event) + + assertTrue(sut.claimPresentation(command)) + sut.recordPresentation(command) + assertFalse(sut.claimPresentation(command)) + } + + @Test + fun `invoke returns Skip after the payment presentation is claimed`() = test { + val event = mock { + on { amountMsat } doReturn 1000000uL + on { paymentHash } doReturn "hash123" + on { paymentId } doReturn "paymentId123" + } + val command = NotifyPaymentReceived.Command.Lightning(event = event) + assertTrue(sut.claimPresentation(command)) + + val result = sut(command) + + assertTrue(result.getOrThrow() is NotifyPaymentReceived.Result.Skip) + verify(activityRepo, never()).isActivitySeen(any()) + } + + @Test + fun `claimPresentation leaves the payment available when presentation is not allowed`() = test { + val event = mock { + on { paymentId } doReturn "paymentId123" + } + val command = NotifyPaymentReceived.Command.Lightning(event = event) + + assertFalse(sut.claimPresentation(command) { false }) + assertTrue(sut.claimPresentation(command)) + } } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 5d721ce3fa..b287e7ef3c 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -11,11 +11,13 @@ import com.synonym.bitkitcore.LnurlException import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Before @@ -32,6 +34,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.isNull @@ -42,6 +45,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore @@ -53,6 +57,7 @@ import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.NodeLifecycleState import to.bitkit.models.OpenChannelResult import to.bitkit.models.TransactionSpeed +import to.bitkit.services.ActivityService import to.bitkit.services.BlocktankService import to.bitkit.services.CoreService import to.bitkit.services.LightningService @@ -102,6 +107,7 @@ class LightningRepoTest : BaseUnitTest() { whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Unit) whenever(lightningService.start(anyOrNull(), any())).thenReturn(Unit) whenever(coreService.isGeoBlocked()).thenReturn(false) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(lightningService.aresRequiredPeersInNetworkGraph()).thenReturn(true) @@ -189,6 +195,105 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `PaymentReceived invalidates the cached invoice without decoding during event handling`() = test { + val bolt11 = "lnbc1" + val activityService = mock() + val processingStarted = CompletableDeferred() + val resumeProcessing = CompletableDeferred() + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(bolt11 = bolt11, bolt11PaymentHash = "010203")) + ) + whenever(coreService.decode(bolt11)).thenThrow(IllegalStateException("decoder unavailable")) + whenever(cacheStore.invalidateReceiveLightningInvoice(bolt11)).thenReturn(true) + whenever(coreService.activity).thenReturn(activityService) + whenever(activityService.handlePaymentEvent("payment-id")).doSuspendableAnswer { + processingStarted.complete(Unit) + resumeProcessing.await() + } + val eventHandler = startNodeAndCaptureEvents() + val event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "010203", + amountMsat = 1_000uL, + customRecords = emptyList(), + ) + val publishedUpdate = async { sut.nodeEventUpdates.first() } + + val handling = async { eventHandler(event) } + processingStarted.await() + + verify(cacheStore).invalidateReceiveLightningInvoice(bolt11) + verify(coreService, never()).decode(bolt11) + assertFalse(publishedUpdate.isCompleted) + + resumeProcessing.complete(Unit) + handling.await() + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertEquals( + SettledReceiveInvoice(bolt11 = bolt11), + update.settledReceiveInvoice, + ) + } + + @Test + fun `PaymentReceived preserves a cached invoice with a different payment hash`() = test { + val bolt11 = "lnbc1" + val activityService = mock() + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(bolt11 = bolt11, bolt11PaymentHash = "010203")) + ) + whenever(coreService.activity).thenReturn(activityService) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "different-payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ) + + val publishedUpdate = async { sut.nodeEventUpdates.first() } + + eventHandler(event) + + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertNull(update.settledReceiveInvoice) + verify(activityService).handlePaymentEvent("payment-id") + } + + @Test + fun `PaymentReceived does not publish settlement when cache invalidation fails`() = test { + val bolt11 = "lnbc1" + val activityService = mock() + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(bolt11 = bolt11, bolt11PaymentHash = "010203")) + ) + whenever( + cacheStore.invalidateReceiveLightningInvoice(bolt11) + ).thenThrow( + IllegalStateException("disk unavailable") + ) + whenever(coreService.activity).thenReturn(activityService) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "010203", + amountMsat = 1_000uL, + customRecords = emptyList(), + ) + val publishedUpdate = async { sut.nodeEventUpdates.first() } + + eventHandler(event) + + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertNull(update.settledReceiveInvoice) + verify(activityService).handlePaymentEvent("payment-id") + } + @Test fun `stop should transition to stopped state`() = test { startNodeForTesting() diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 5b464a4ba9..57466fc4ae 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -62,6 +62,7 @@ class WalletRepoTest : BaseUnitTest() { const val ADDRESS = "bc1qTest" const val ADDRESS_NEW = "newAddress" const val INVOICE = "testInvoice" + const val INVOICE_REPLACEMENT = "replacementInvoice" const val SATS = 1000uL val error = RuntimeException("Test Error") val channels = listOf( @@ -89,7 +90,7 @@ class WalletRepoTest : BaseUnitTest() { whenever { cacheStore.update(any()) }.thenReturn(Unit) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) - whenever(lightningRepo.nodeEvents).thenReturn(MutableSharedFlow()) + whenever(lightningRepo.nodeEventUpdates).thenReturn(MutableSharedFlow()) whenever(lightningRepo.listSpendableOutputs()).thenReturn(Result.success(emptyList())) whenever(lightningRepo.calculateTotalFee(any(), any(), any(), any(), anyOrNull())) .thenReturn(Result.success(SATS)) @@ -392,7 +393,7 @@ class WalletRepoTest : BaseUnitTest() { sut.setBolt11(INVOICE) assertEquals(INVOICE, sut.walletState.value.bolt11) - verify(cacheStore).saveBolt11(INVOICE) + verify(cacheStore).saveBolt11(INVOICE, "") } @Test @@ -631,7 +632,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21ForEvent PaymentReceived should refresh address if used`() = test { - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS, bolt11 = INVOICE))) whenever(coreService.isAddressUsed(any())).thenReturn(true) sut = createSut() sut.loadFromCache() @@ -641,8 +642,9 @@ class WalletRepoTest : BaseUnitTest() { paymentId = "testPaymentId", paymentHash = "testPaymentHash", amountMsat = 1000uL, - customRecords = emptyList() - ) + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), ) verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() @@ -650,7 +652,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21ForEvent PaymentReceived should not refresh address if not used`() = test { - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS, bolt11 = INVOICE))) whenever(coreService.isAddressUsed(any())).thenReturn(false) sut = createSut() sut.loadFromCache() @@ -660,13 +662,108 @@ class WalletRepoTest : BaseUnitTest() { paymentId = "testPaymentId", paymentHash = "testPaymentHash", amountMsat = 1000uL, - customRecords = emptyList() - ) + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), ) verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() } + @Test + fun `refreshBip21ForEvent PaymentReceived should invalidate the settled invoice`() = test { + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) + .thenReturn(Result.success(INVOICE_REPLACEMENT)) + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE") + + sut.refreshBip21ForEvent( + Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "testPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), + ) + + assertEquals("", sut.walletState.value.bolt11) + assertFalse(sut.walletState.value.bip21.contains(INVOICE)) + assertTrue(sut.walletState.value.bip21.contains(ADDRESS)) + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) + verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) + verify(preActivityMetadataRepo, never()).addPreActivityMetadata(any()) + } + + @Test + fun `refreshBip21ForEvent PaymentReceived should preserve an unrelated receive invoice`() = test { + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE") + + sut.refreshBip21ForEvent( + Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "unrelatedPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList(), + ), + ) + + assertEquals(INVOICE, sut.walletState.value.bolt11) + assertTrue(sut.walletState.value.bip21.contains(INVOICE)) + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) + } + + @Test + fun `refreshBip21ForEvent PaymentReceived should preserve a replacement invoice`() = test { + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE_REPLACEMENT) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE_REPLACEMENT") + + sut.refreshBip21ForEvent( + event = Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "testPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), + ) + + assertEquals(INVOICE_REPLACEMENT, sut.walletState.value.bolt11) + assertTrue(sut.walletState.value.bip21.contains(INVOICE_REPLACEMENT)) + verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() + } + + @Test + fun `refreshBip21 should create a fresh invoice after PaymentReceived invalidates the old one`() = test { + whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) + .thenReturn(Result.success(INVOICE_REPLACEMENT)) + sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE) + sut.setBip21("bitcoin:$ADDRESS?lightning=$INVOICE") + + sut.refreshBip21ForEvent( + Event.PaymentReceived( + paymentId = "testPaymentId", + paymentHash = "testPaymentHash", + amountMsat = 1000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), + ) + assertEquals("", sut.walletState.value.bolt11) + + sut.refreshBip21() + + assertEquals(INVOICE_REPLACEMENT, sut.walletState.value.bolt11) + assertTrue(sut.walletState.value.bip21.contains(INVOICE_REPLACEMENT)) + } + @Test fun `wipeWallet should call use case`() = test { whenever(wipeWalletUseCase.invoke(any(), any(), any())).thenReturn(Result.success(Unit)) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b2594b1839..6ce9051b8f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1,5 +1,6 @@ package to.bitkit.viewmodels +import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -12,12 +13,15 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.NetworkType import com.synonym.bitkitcore.Scanner import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -26,21 +30,28 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import to.bitkit.App +import to.bitkit.CurrentActivity import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain 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.HwWalletReceivedTx +import to.bitkit.models.NewTransactionSheetDetails +import to.bitkit.models.NewTransactionSheetDirection +import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest @@ -57,6 +68,7 @@ import to.bitkit.repositories.HealthRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState +import to.bitkit.repositories.NodeEventUpdate import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo import to.bitkit.repositories.PendingPaymentResolution @@ -65,6 +77,7 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.SamRockRepo +import to.bitkit.repositories.SettledReceiveInvoice import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState @@ -137,7 +150,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val settingsData = MutableStateFlow(SettingsData()) private val isPaykitEnabled = MutableStateFlow(false) private val walletState = MutableStateFlow(WalletState()) - private val nodeEvents = MutableSharedFlow() + private val nodeEventUpdates = MutableSharedFlow() private val pubkyPublicKey = MutableStateFlow(null) private val pubkyContacts = MutableStateFlow>(emptyList()) private val pubkyContactsLoadVersion = MutableStateFlow(0L) @@ -151,6 +164,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut = createViewModel() } + @After + fun tearDown() { + App.currentActivity = null + } + @Suppress("LongMethod") private fun stubRepositories() { whenever(context.getString(any())).thenReturn("") @@ -158,13 +176,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(connectivityRepo.isOnline).thenReturn(MutableStateFlow(ConnectivityState.CONNECTED)) whenever(healthRepo.healthState).thenReturn(MutableStateFlow(mock())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - whenever(lightningRepo.nodeEvents).thenReturn(nodeEvents) + whenever(lightningRepo.nodeEventUpdates).thenReturn(nodeEventUpdates) + whenever(lightningRepo.nodeEvents).thenReturn(nodeEventUpdates.map { it.event }) whenever(hwWalletRepo.receivedTxs).thenReturn(hwReceivedTxs) whenever(hwWalletRepo.needsPairingCode).thenReturn(needsPairingCode) whenever(hwWalletRepo.pairingCodeRequestId).thenReturn(pairingCodeRequestId) whenever(coreService.activity).thenReturn(activityService) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(walletRepo.walletState).thenReturn(walletState) + whenever(walletRepo.getBolt11()).thenAnswer { walletState.value.bolt11 } whenever(walletRepo.walletExists()).thenReturn(true) whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() @@ -200,6 +220,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(any()) } .thenReturn(null) + whenever { publicPaykitRepo.refreshPublishedBolt11ForPayment(any()) } + .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.handleReceivedPayment(any()) } + .thenReturn(Result.success(Unit)) + whenever { notifyPaymentReceivedHandler(any()) } + .thenReturn(Result.success(NotifyPaymentReceived.Result.Skip)) whenever { privatePaykitRepo.contactPublicKeyForPrivateOnchainAddresses(any>()) } .thenReturn(null) whenever { privatePaykitRepo.discardRemoteLightningEndpoints(any(), any()) } @@ -265,6 +291,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyRepo = pubkyRepo, ) + private suspend fun emitNodeEvent( + event: Event, + settledReceiveInvoice: SettledReceiveInvoice? = null, + ) { + nodeEventUpdates.emit( + NodeEventUpdate( + event = event, + settledReceiveInvoice = settledReceiveInvoice, + ), + ) + } + @Test fun `onUsbDeviceAttached forwards to the hardware wallet repo`() = test { sut.onUsbDeviceAttached() @@ -848,7 +886,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() setPendingContactPaymentContext(paymentHash, contactKey) - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -870,7 +908,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() setPendingContactPaymentContext(paymentHash, "pubkycontact") - nodeEvents.emit( + emitNodeEvent( Event.PaymentFailed( paymentId = "payment_id", paymentHash = paymentHash, @@ -899,7 +937,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.showSheet(Sheet.Send()) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentFailed( paymentId = "payment_id", paymentHash = paymentHash, @@ -911,6 +949,220 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) } + @Test + fun `received lightning payment closes the active receive sheet after wallet invoice is cleared`() = test { + walletState.value = WalletState(bolt11 = "settled-invoice") + sut.showSheet(Sheet.Receive()) + advanceUntilIdle() + walletState.value = WalletState(bolt11 = "") + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(activityRepo).handlePaymentEvent("payment-hash") + verify(notifyPaymentReceivedHandler).invoke(any()) + } + + @Test + fun `received unrelated lightning payment preserves the active receive sheet`() = test { + val sheet = Sheet.Receive() + sut.showSheet(sheet) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "unrelated-payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + assertEquals(sheet, sut.currentSheet.value) + verify(activityRepo).handlePaymentEvent("unrelated-payment-hash") + } + + @Test + fun `received lightning payment preserves a non-receive sheet`() = test { + val sheet = Sheet.Pin() + walletState.value = WalletState(bolt11 = "settled-invoice") + sut.showSheet(sheet) + advanceUntilIdle() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), + ) + advanceUntilIdle() + + assertEquals(sheet, sut.currentSheet.value) + } + + @Test + fun `received lightning payment is claimed by the UI while foregrounded`() = test { + val details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "payment-hash", + sats = 1L, + ) + whenever(notifyPaymentReceivedHandler(any())) + .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) + whenever(notifyPaymentReceivedHandler.claimPresentation(any())).thenReturn(true) + App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler).claimPresentation(any()) + verify(notifyPaymentReceivedHandler).recordPresentation(any()) + assertEquals(details, sut.transactionSheet.value) + } + + @Test + fun `received lightning payment uses the UI handoff after the app backgrounds`() = test { + val details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "payment-hash", + sats = 1L, + ) + whenever(notifyPaymentReceivedHandler(any())) + .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) + whenever(notifyPaymentReceivedHandler.claimPresentation(any())).thenReturn(true) + App.currentActivity = CurrentActivity() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler).claimPresentation(any()) + verify(notifyPaymentReceivedHandler).recordPresentation(any()) + assertEquals(details, sut.transactionSheet.value) + } + + @Test + fun `received lightning payment skips the sheet when the service owns the presentation`() = test { + val details = NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.RECEIVED, + paymentHashOrTxId = "payment-hash", + sats = 1L, + ) + whenever(notifyPaymentReceivedHandler(any())) + .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) + whenever(notifyPaymentReceivedHandler.claimPresentation(any())).thenReturn(false) + App.currentActivity = CurrentActivity() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler).claimPresentation(any()) + verify(notifyPaymentReceivedHandler, never()).recordPresentation(any()) + assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) + } + + @Test + fun `received lightning payment preserves a receive sheet opened during activity sync`() = test { + val processingStarted = CompletableDeferred() + val resumeProcessing = CompletableDeferred() + whenever(activityRepo.handlePaymentEvent("payment-hash")).doSuspendableAnswer { + processingStarted.complete(Unit) + resumeProcessing.await() + } + walletState.value = WalletState(bolt11 = "settled-invoice") + sut.showSheet(Sheet.Receive()) + advanceUntilIdle() + + emitNodeEvent( + event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), + ) + processingStarted.await() + + walletState.value = WalletState(bolt11 = "replacement-invoice") + val replacementSheet = Sheet.Receive() + sut.showSheet(replacementSheet) + advanceUntilIdle() + resumeProcessing.complete(Unit) + advanceUntilIdle() + + assertTrue(sut.currentSheet.value === replacementSheet) + } + + @Test + fun `received lightning payment preserves a receive sheet whose invoice changes during activity sync`() = test { + val processingStarted = CompletableDeferred() + val resumeProcessing = CompletableDeferred() + whenever(activityRepo.handlePaymentEvent("payment-hash")).doSuspendableAnswer { + processingStarted.complete(Unit) + resumeProcessing.await() + } + walletState.value = WalletState(bolt11 = "settled-invoice") + val receiveSheet = Sheet.Receive() + sut.showSheet(receiveSheet) + advanceUntilIdle() + + emitNodeEvent( + event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), + ) + processingStarted.await() + + walletState.value = WalletState(bolt11 = "replacement-invoice") + advanceUntilIdle() + resumeProcessing.complete(Unit) + advanceUntilIdle() + + assertTrue(sut.currentSheet.value === receiveSheet) + } + @Test fun `preserveContactPaymentContext moves active context to pending`() = test { val paymentHash = "pending_hash" @@ -1087,7 +1339,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -1164,7 +1416,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -1183,7 +1435,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() clearInvocations(publicPaykitRepo) - nodeEvents.emit( + emitNodeEvent( Event.ChannelReady( channelId = "testChannelId", userChannelId = "testUserChannelId", @@ -1203,7 +1455,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() clearInvocations(publicPaykitRepo) - nodeEvents.emit( + emitNodeEvent( Event.ChannelClosed( channelId = "testChannelId", userChannelId = "testUserChannelId", diff --git a/changelog.d/next/1086.fixed.md b/changelog.d/next/1086.fixed.md new file mode 100644 index 0000000000..972cc48b82 --- /dev/null +++ b/changelog.d/next/1086.fixed.md @@ -0,0 +1 @@ +Receive requests now refresh reliably after a payment is completed.