From 3259df97b60f3b9a06f723bb1fc67a1e3fe75d06 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 21:18:10 +0200 Subject: [PATCH 1/7] fix: refresh receive requests after payment --- .../main/java/to/bitkit/data/CacheStore.kt | 6 +++ .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 2 + .../to/bitkit/repositories/LightningRepo.kt | 5 ++ .../java/to/bitkit/repositories/WalletRepo.kt | 16 +++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++ .../java/to/bitkit/data/AppCacheDataTest.kt | 26 ++++++++++ .../java/to/bitkit/fcm/WakeNodeWorkerTest.kt | 3 ++ .../bitkit/repositories/LightningRepoTest.kt | 33 ++++++++++++ .../to/bitkit/repositories/WalletRepoTest.kt | 52 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 45 ++++++++++++++++ 10 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/to/bitkit/data/AppCacheDataTest.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 477d4f3c94..8095da293a 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -63,6 +63,10 @@ class CacheStore @Inject constructor( store.updateData { it.copy(bip21 = bip21) } } + suspend fun invalidateReceiveLightningInvoice() { + store.updateData { it.invalidateReceiveLightningInvoice() } + } + suspend fun cacheBalance(balanceState: BalanceState) { store.updateData { it.copy(balance = balanceState) } } @@ -133,4 +137,6 @@ data class AppCacheData( val addressSearchLastUsedChangeIndexes: Map = mapOf(), ) { fun resetBip21() = copy(bip21 = "", bolt11 = "", onchainAddress = "") + + fun invalidateReceiveLightningInvoice() = copy(bip21 = "", bolt11 = "") } diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index 53102d0a44..cc0e0c9e8c 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -188,6 +188,8 @@ class WakeNodeWorker @AssistedInject constructor( } private suspend fun onPaymentReceived(event: Event.PaymentReceived) { + cacheStore.invalidateReceiveLightningInvoice() + val sats = msatCeilOf(event.amountMsat) // Save for UI to pick up cacheStore.setBackgroundReceive( diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1e8800355c..f1580c6332 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -439,6 +439,11 @@ class LightningRepo @Inject constructor( private suspend fun onEvent(event: Event) { handleLdkEvent(event) recordProbeOutcome(event) + if (event is Event.PaymentReceived) { + val paymentId = event.paymentId ?: event.paymentHash + runSuspendCatching { coreService.activity.handlePaymentEvent(paymentId) } + .onFailure { Logger.error("Failed to record received payment '$paymentId'", it, context = TAG) } + } _eventHandlers.toList().forEach { runCatching { it.invoke(event) } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 681f7bd74f..aa155e60fd 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -334,8 +334,14 @@ class WalletRepo @Inject constructor( } } - is Event.PaymentReceived, is Event.OnchainTransactionReceived -> { - // Check if onchain address was used, generate new one if needed + is Event.PaymentReceived -> { + Logger.debug("refreshBip21ForEvent: $event", context = TAG) + invalidateSettledReceiveInvoice() + updateBip21Url() + refreshAddressIfNeeded() + } + + is Event.OnchainTransactionReceived -> { Logger.debug("refreshBip21ForEvent: $event", context = TAG) refreshAddressIfNeeded() updateBip21Url() @@ -349,6 +355,12 @@ class WalletRepo @Inject constructor( refreshReusableReceiveAddress() } + private suspend fun invalidateSettledReceiveInvoice() { + _walletState.update { it.copy(bolt11 = "", bip21 = "") } + runSuspendCatching { cacheStore.invalidateReceiveLightningInvoice() } + .onFailure { Logger.error("Failed to invalidate settled receive invoice", it, context = TAG) } + } + suspend fun refreshReusableReceiveAddress(): Result = withContext(bgDispatcher) { runSuspendCatching { refreshReusableReceiveAddressOrThrow() diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 317da179db..d405aa2ef5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -915,6 +915,9 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) + if (_currentSheet.value is Sheet.Receive) { + hideSheet() + } privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash)?.let { publicKey -> activityRepo.setContact( contactPublicKey = publicKey, 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..58ad1db63e --- /dev/null +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -0,0 +1,26 @@ +package to.bitkit.data + +import org.junit.Test +import to.bitkit.models.BalanceState +import kotlin.test.assertEquals + +class AppCacheDataTest { + @Test + fun `invalidateReceiveLightningInvoice clears the paid request and preserves other cache data`() { + val cache = AppCacheData( + onchainAddress = "bc1qtest", + bolt11 = "paidInvoice", + bip21 = "bitcoin:bc1qtest?lightning=paidInvoice", + balance = BalanceState(totalOnchainSats = 42uL), + paidOrders = mapOf("order" to "txid"), + ) + + val invalidated = cache.invalidateReceiveLightningInvoice() + + assertEquals("", invalidated.bolt11) + 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/fcm/WakeNodeWorkerTest.kt b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt index 74534dc46f..84b5528e95 100644 --- a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt +++ b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt @@ -109,6 +109,7 @@ class WakeNodeWorkerTest : BaseUnitTest() { assertNull(findNotificationByTitle(receivedTitle), "A non-CJIT channel must not show a payment notification") verify(activityRepo, never()).insertActivityFromCjit(any(), any()) verify(cacheStore, never()).setBackgroundReceive(any()) + verify(cacheStore, never()).invalidateReceiveLightningInvoice() } @Test @@ -174,6 +175,7 @@ class WakeNodeWorkerTest : BaseUnitTest() { val notification = findNotificationByTitle(title) assertNotNull(notification, "Payment notification should be delivered when app is killed") assertEquals(body, notification?.extras?.getString(Notification.EXTRA_TEXT)) + verify(cacheStore).invalidateReceiveLightningInvoice() } @Test @@ -193,6 +195,7 @@ class WakeNodeWorkerTest : BaseUnitTest() { "Notification is deduped when the foreground service handles it", ) // Receive is still cached for the in-app UI to pick up + verify(cacheStore).invalidateReceiveLightningInvoice() verify(cacheStore).setBackgroundReceive(any()) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 5d721ce3fa..b1394c6e57 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 @@ -53,6 +56,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 @@ -189,6 +193,35 @@ class LightningRepoTest : BaseUnitTest() { } } + @Test + fun `PaymentReceived records activity before publishing the event`() = test { + val activityService = mock() + val processingStarted = CompletableDeferred() + val resumeProcessing = CompletableDeferred() + 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 = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ) + val publishedEvent = async { sut.nodeEvents.first() } + + val handling = async { eventHandler(event) } + processingStarted.await() + + assertFalse(publishedEvent.isCompleted) + + resumeProcessing.complete(Unit) + handling.await() + assertEquals(event, publishedEvent.await()) + } + @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..c9e4ac991f 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( @@ -667,6 +668,57 @@ class WalletRepoTest : BaseUnitTest() { 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() + ) + ) + + assertEquals("", sut.walletState.value.bolt11) + assertFalse(sut.walletState.value.bip21.contains(INVOICE)) + assertTrue(sut.walletState.value.bip21.contains(ADDRESS)) + verify(cacheStore).invalidateReceiveLightningInvoice() + verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) + verify(preActivityMetadataRepo, never()).addPreActivityMetadata(any()) + } + + @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() + ) + ) + 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..37059e50fd 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -38,6 +38,7 @@ 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 @@ -200,6 +201,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()) } @@ -911,6 +918,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) } + @Test + fun `received lightning payment closes the active receive sheet`() = test { + sut.showSheet(Sheet.Receive()) + advanceUntilIdle() + + nodeEvents.emit( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(activityRepo).handlePaymentEvent("payment-hash") + } + + @Test + fun `received lightning payment preserves a non-receive sheet`() = test { + val sheet = Sheet.Pin() + sut.showSheet(sheet) + advanceUntilIdle() + + nodeEvents.emit( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + assertEquals(sheet, sut.currentSheet.value) + } + @Test fun `preserveContactPaymentContext moves active context to pending`() = test { val paymentHash = "pending_hash" From 30009ac208c7d95c5c8d23dc7521074005aaba12 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 21:23:47 +0200 Subject: [PATCH 2/7] fix: persist settled invoice invalidation first --- app/src/main/java/to/bitkit/repositories/LightningRepo.kt | 3 +++ app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index f1580c6332..89fa12f7a6 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -65,6 +65,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 @@ -440,6 +441,8 @@ class LightningRepo @Inject constructor( handleLdkEvent(event) recordProbeOutcome(event) if (event is Event.PaymentReceived) { + runSuspendCatching { cacheStore.invalidateReceiveLightningInvoice() } + .onFailure { Logger.error("Failed to invalidate settled receive invoice", it, context = TAG) } val paymentId = event.paymentId ?: event.paymentHash runSuspendCatching { coreService.activity.handlePaymentEvent(paymentId) } .onFailure { Logger.error("Failed to record received payment '$paymentId'", it, context = TAG) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index b1394c6e57..5d0c9071a3 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -194,7 +194,7 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `PaymentReceived records activity before publishing the event`() = test { + fun `PaymentReceived invalidates the cached invoice before awaiting activity and publishing the event`() = test { val activityService = mock() val processingStarted = CompletableDeferred() val resumeProcessing = CompletableDeferred() @@ -215,6 +215,7 @@ class LightningRepoTest : BaseUnitTest() { val handling = async { eventHandler(event) } processingStarted.await() + verify(cacheStore).invalidateReceiveLightningInvoice() assertFalse(publishedEvent.isCompleted) resumeProcessing.complete(Unit) From cb745531af22a09a8de96dd3381d0beaf6535bad Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 12:23:59 +0200 Subject: [PATCH 3/7] fix: document receive request refresh --- changelog.d/next/1086.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1086.fixed.md 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. From 199862ff1f79908b6dff27d452bd541d56f59239 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 13:15:20 +0200 Subject: [PATCH 4/7] fix: scope receive invoice settlement --- .../androidServices/LightningNodeService.kt | 1 + .../main/java/to/bitkit/data/CacheStore.kt | 11 ++++-- .../main/java/to/bitkit/fcm/WakeNodeWorker.kt | 2 -- .../to/bitkit/repositories/LightningRepo.kt | 36 +++++++++++++++++-- .../java/to/bitkit/repositories/WalletRepo.kt | 9 +++-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 ++- .../LightningNodeServiceTest.kt | 1 + .../java/to/bitkit/fcm/WakeNodeWorkerTest.kt | 4 +-- .../bitkit/repositories/LightningRepoTest.kt | 33 +++++++++++++++-- .../to/bitkit/repositories/WalletRepoTest.kt | 26 +++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 22 ++++++++++++ 11 files changed, 134 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index fd70ca71e8..91317b8a9c 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -105,6 +105,7 @@ class LightningNodeService : Service() { private suspend fun handlePaymentReceived(event: Event) { if (event !is Event.PaymentReceived && event !is Event.OnchainTransactionReceived) return + if (App.currentActivity?.value != null) return val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return notifyPaymentReceivedHandler(command).onSuccess { diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 8095da293a..5d68537e0a 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -63,8 +63,15 @@ class CacheStore @Inject constructor( store.updateData { it.copy(bip21 = bip21) } } - suspend fun invalidateReceiveLightningInvoice() { - store.updateData { it.invalidateReceiveLightningInvoice() } + suspend fun invalidateReceiveLightningInvoice(expectedBolt11: String? = null): Boolean { + val updated = store.updateData { + if (expectedBolt11 == null || it.bolt11 == expectedBolt11) { + it.invalidateReceiveLightningInvoice() + } else { + it + } + } + return updated.bolt11.isEmpty() } suspend fun cacheBalance(balanceState: BalanceState) { diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index cc0e0c9e8c..53102d0a44 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -188,8 +188,6 @@ class WakeNodeWorker @AssistedInject constructor( } private suspend fun onPaymentReceived(event: Event.PaymentReceived) { - cacheStore.invalidateReceiveLightningInvoice() - val sats = msatCeilOf(event.amountMsat) // Save for UI to pick up cacheStore.setBackgroundReceive( diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 89fa12f7a6..22a123dbaf 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -66,6 +66,7 @@ import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.toHex import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS @@ -74,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 @@ -89,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 @@ -142,6 +143,7 @@ class LightningRepo @Inject constructor( private val syncMutex = Mutex() private val syncPending = AtomicBoolean(false) private val syncRetryJob = AtomicReference(null) + private val lastSettledReceiveInvoicePaymentHash = AtomicReference(null) private val lifecycleMutex = Mutex() private val isChangingAddressType = AtomicBoolean(false) @@ -441,8 +443,7 @@ class LightningRepo @Inject constructor( handleLdkEvent(event) recordProbeOutcome(event) if (event is Event.PaymentReceived) { - runSuspendCatching { cacheStore.invalidateReceiveLightningInvoice() } - .onFailure { Logger.error("Failed to invalidate settled receive invoice", it, context = TAG) } + 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) } @@ -453,6 +454,35 @@ class LightningRepo @Inject constructor( _nodeEvents.emit(event) } + private suspend fun invalidateSettledReceiveInvoice(event: Event.PaymentReceived) { + lastSettledReceiveInvoicePaymentHash.set(null) + val cachedBolt11 = runSuspendCatching { + cacheStore.data.first().bolt11 + }.onFailure { + Logger.error("Failed to read cached receive invoice", it, context = TAG) + }.getOrNull() ?: return + if (cachedBolt11.isEmpty()) return + + val cachedPaymentHash = runSuspendCatching { + (coreService.decode(cachedBolt11) as? Scanner.Lightning)?.invoice?.paymentHash?.toHex() + }.onFailure { + Logger.error("Failed to decode cached receive invoice", it, context = TAG) + }.getOrNull() + if (cachedPaymentHash != event.paymentHash) return + + val invalidated = runSuspendCatching { + cacheStore.invalidateReceiveLightningInvoice(expectedBolt11 = cachedBolt11) + }.onFailure { + Logger.error("Failed to invalidate settled receive invoice", it, context = TAG) + }.getOrDefault(true) + if (invalidated) { + lastSettledReceiveInvoicePaymentHash.set(event.paymentHash) + } + } + + fun wasCurrentReceiveInvoiceSettled(paymentHash: PaymentHash): Boolean = + lastSettledReceiveInvoicePaymentHash.get() == paymentHash + fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled } suspend fun updateGeoBlockState() = withContext(bgDispatcher) { diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index aa155e60fd..923274e73d 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -36,6 +36,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 +46,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 @@ -334,13 +334,15 @@ class WalletRepo @Inject constructor( } } - is Event.PaymentReceived -> { + is Event.PaymentReceived if lightningRepo.wasCurrentReceiveInvoiceSettled(event.paymentHash) -> { Logger.debug("refreshBip21ForEvent: $event", context = TAG) invalidateSettledReceiveInvoice() updateBip21Url() refreshAddressIfNeeded() } + is Event.PaymentReceived -> Unit + is Event.OnchainTransactionReceived -> { Logger.debug("refreshBip21ForEvent: $event", context = TAG) refreshAddressIfNeeded() @@ -356,8 +358,9 @@ class WalletRepo @Inject constructor( } private suspend fun invalidateSettledReceiveInvoice() { + val settledBolt11 = _walletState.value.bolt11 _walletState.update { it.copy(bolt11 = "", bip21 = "") } - runSuspendCatching { cacheStore.invalidateReceiveLightningInvoice() } + runSuspendCatching { cacheStore.invalidateReceiveLightningInvoice(expectedBolt11 = settledBolt11) } .onFailure { Logger.error("Failed to invalidate settled receive invoice", it, context = TAG) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d405aa2ef5..0e149f8a43 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -915,7 +915,10 @@ class AppViewModel @Inject constructor( private suspend fun handlePaymentReceived(event: Event.PaymentReceived) { event.paymentHash.let { paymentHash -> activityRepo.handlePaymentEvent(paymentHash) - if (_currentSheet.value is Sheet.Receive) { + if ( + _currentSheet.value is Sheet.Receive && + lightningRepo.wasCurrentReceiveInvoiceSettled(paymentHash) + ) { hideSheet() } privatePaykitRepo.contactPublicKeyForPrivateInvoicePaymentHash(paymentHash)?.let { publicKey -> diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 27f09f40d2..201a436724 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -337,6 +337,7 @@ class LightningNodeServiceTest : BaseUnitTest() { assertNull(paymentNotification, "Payment notification should NOT be present in foreground") verify(cacheStore, never()).setBackgroundReceive(any()) + verify(notifyPaymentReceivedHandler, never()).invoke(any()) } @Test diff --git a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt index 84b5528e95..25cba37a4b 100644 --- a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt +++ b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt @@ -175,7 +175,7 @@ class WakeNodeWorkerTest : BaseUnitTest() { val notification = findNotificationByTitle(title) assertNotNull(notification, "Payment notification should be delivered when app is killed") assertEquals(body, notification?.extras?.getString(Notification.EXTRA_TEXT)) - verify(cacheStore).invalidateReceiveLightningInvoice() + verify(cacheStore, never()).invalidateReceiveLightningInvoice() } @Test @@ -195,7 +195,7 @@ class WakeNodeWorkerTest : BaseUnitTest() { "Notification is deduped when the foreground service handles it", ) // Receive is still cached for the in-app UI to pick up - verify(cacheStore).invalidateReceiveLightningInvoice() + verify(cacheStore, never()).invalidateReceiveLightningInvoice() verify(cacheStore).setBackgroundReceive(any()) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 5d0c9071a3..03f3c338e8 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -45,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 @@ -106,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) @@ -195,9 +197,13 @@ class LightningRepoTest : BaseUnitTest() { @Test fun `PaymentReceived invalidates the cached invoice before awaiting activity and publishing the event`() = test { + val bolt11 = "lnbc1" val activityService = mock() val processingStarted = CompletableDeferred() val resumeProcessing = CompletableDeferred() + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(bolt11 = bolt11))) + whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11))) + whenever(cacheStore.invalidateReceiveLightningInvoice(bolt11)).thenReturn(true) whenever(coreService.activity).thenReturn(activityService) whenever { activityService.handlePaymentEvent("payment-id") }.doSuspendableAnswer { processingStarted.complete(Unit) @@ -206,7 +212,7 @@ class LightningRepoTest : BaseUnitTest() { val eventHandler = startNodeAndCaptureEvents() val event = Event.PaymentReceived( paymentId = "payment-id", - paymentHash = "payment-hash", + paymentHash = "010203", amountMsat = 1_000uL, customRecords = emptyList(), ) @@ -215,7 +221,8 @@ class LightningRepoTest : BaseUnitTest() { val handling = async { eventHandler(event) } processingStarted.await() - verify(cacheStore).invalidateReceiveLightningInvoice() + verify(cacheStore).invalidateReceiveLightningInvoice(bolt11) + assertTrue(sut.wasCurrentReceiveInvoiceSettled(event.paymentHash)) assertFalse(publishedEvent.isCompleted) resumeProcessing.complete(Unit) @@ -223,6 +230,28 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(event, publishedEvent.await()) } + @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))) + whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11))) + whenever(coreService.activity).thenReturn(activityService) + val eventHandler = startNodeAndCaptureEvents() + val event = Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "different-payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ) + + eventHandler(event) + + verify(cacheStore, never()).invalidateReceiveLightningInvoice(anyOrNull()) + assertFalse(sut.wasCurrentReceiveInvoiceSettled(event.paymentHash)) + 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 c9e4ac991f..da69fd6546 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -634,6 +634,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21ForEvent PaymentReceived should refresh address if used`() = test { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) whenever(coreService.isAddressUsed(any())).thenReturn(true) + whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("testPaymentHash")).thenReturn(true) sut = createSut() sut.loadFromCache() @@ -653,6 +654,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21ForEvent PaymentReceived should not refresh address if not used`() = test { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) whenever(coreService.isAddressUsed(any())).thenReturn(false) + whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("testPaymentHash")).thenReturn(true) sut = createSut() sut.loadFromCache() @@ -671,6 +673,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21ForEvent PaymentReceived should invalidate the settled invoice`() = test { whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("testPaymentHash")).thenReturn(true) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) .thenReturn(Result.success(INVOICE_REPLACEMENT)) sut.setOnchainAddress(ADDRESS) @@ -689,14 +692,35 @@ class WalletRepoTest : BaseUnitTest() { assertEquals("", sut.walletState.value.bolt11) assertFalse(sut.walletState.value.bip21.contains(INVOICE)) assertTrue(sut.walletState.value.bip21.contains(ADDRESS)) - verify(cacheStore).invalidateReceiveLightningInvoice() + verify(cacheStore).invalidateReceiveLightningInvoice(INVOICE) 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(anyOrNull()) + } + @Test fun `refreshBip21 should create a fresh invoice after PaymentReceived invalidates the old one`() = test { whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("testPaymentHash")).thenReturn(true) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) .thenReturn(Result.success(INVOICE_REPLACEMENT)) sut.setOnchainAddress(ADDRESS) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 37059e50fd..d9cadd7c42 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -920,6 +920,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `received lightning payment closes the active receive sheet`() = test { + whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("payment-hash")).thenReturn(true) sut.showSheet(Sheet.Receive()) advanceUntilIdle() @@ -935,6 +936,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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() + + nodeEvents.emit( + 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 From 0e7f432c14f2b8b39d84fd0f087873a1ac1844b5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 14:00:06 +0200 Subject: [PATCH 5/7] fix: correlate receive settlement events --- .../androidServices/LightningNodeService.kt | 16 +- .../commands/NotifyPaymentReceivedHandler.kt | 28 ++- .../to/bitkit/repositories/LightningRepo.kt | 55 ++++-- .../java/to/bitkit/repositories/WalletRepo.kt | 28 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 55 +++++- .../LightningNodeServiceTest.kt | 84 +++++++- .../NotifyPaymentReceivedHandlerTest.kt | 17 ++ .../bitkit/repositories/LightningRepoTest.kt | 47 ++++- .../to/bitkit/repositories/WalletRepoTest.kt | 45 +++-- .../viewmodels/AppViewModelSendFlowTest.kt | 183 ++++++++++++++++-- 10 files changed, 474 insertions(+), 84 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 91317b8a9c..33bf067619 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -105,13 +105,15 @@ class LightningNodeService : Service() { private suspend fun handlePaymentReceived(event: Event) { if (event !is Event.PaymentReceived && event !is Event.OnchainTransactionReceived) return - if (App.currentActivity?.value != null) 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("Payment notification result: $result", context = TAG) + if (result !is NotifyPaymentReceived.Result.ShowNotification) return + if (App.currentActivity?.value != null) return + notifyPaymentReceivedHandler.markPresented(command).onSuccess { + showPaymentNotification(result.sheet, result.notification) + } } } @@ -134,10 +136,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/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 179b624e18..7e632ca6b5 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 @@ -22,13 +23,13 @@ class NotifyPaymentReceivedHandler @Inject constructor( suspend operator fun invoke( command: NotifyPaymentReceived.Command, ): Result = withContext(ioDispatcher) { - runCatching { + runSuspendCatching { 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 +44,15 @@ class NotifyPaymentReceivedHandler @Inject constructor( } } + suspend fun markPresented(command: NotifyPaymentReceived.Command): Result = withContext(ioDispatcher) { + runSuspendCatching { markAsSeen(command) } + .onFailure { Logger.error("Failed to mark payment notification as presented", it, context = TAG) } + } + 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 +64,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 22a123dbaf..cb77a3711b 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -127,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()) @@ -143,7 +144,6 @@ class LightningRepo @Inject constructor( private val syncMutex = Mutex() private val syncPending = AtomicBoolean(false) private val syncRetryJob = AtomicReference(null) - private val lastSettledReceiveInvoicePaymentHash = AtomicReference(null) private val lifecycleMutex = Mutex() private val isChangingAddressType = AtomicBoolean(false) @@ -442,47 +442,57 @@ class LightningRepo @Inject constructor( private suspend fun onEvent(event: Event) { handleLdkEvent(event) recordProbeOutcome(event) - if (event is Event.PaymentReceived) { - invalidateSettledReceiveInvoice(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) { - lastSettledReceiveInvoicePaymentHash.set(null) + private suspend fun invalidateSettledReceiveInvoice(event: Event.PaymentReceived): SettledReceiveInvoice? { val cachedBolt11 = runSuspendCatching { cacheStore.data.first().bolt11 }.onFailure { Logger.error("Failed to read cached receive invoice", it, context = TAG) - }.getOrNull() ?: return - if (cachedBolt11.isEmpty()) return + }.getOrNull() ?: return null + if (cachedBolt11.isEmpty()) return null val cachedPaymentHash = runSuspendCatching { (coreService.decode(cachedBolt11) as? Scanner.Lightning)?.invoice?.paymentHash?.toHex() }.onFailure { Logger.error("Failed to decode cached receive invoice", it, context = TAG) }.getOrNull() - if (cachedPaymentHash != event.paymentHash) return + 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(true) - if (invalidated) { - lastSettledReceiveInvoicePaymentHash.set(event.paymentHash) + }.getOrDefault(false) + + return if (invalidated) { + SettledReceiveInvoice( + bolt11 = cachedBolt11, + paymentHash = event.paymentHash, + ) + } else { + null } } - fun wasCurrentReceiveInvoiceSettled(paymentHash: PaymentHash): Boolean = - lastSettledReceiveInvoicePaymentHash.get() == paymentHash - fun setRecoveryMode(enabled: Boolean) = _isRecoveryMode.update { enabled } suspend fun updateGeoBlockState() = withContext(bgDispatcher) { @@ -1767,6 +1777,17 @@ 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, + val paymentHash: PaymentHash, +) + 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 923274e73d..add402dac7 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -83,9 +83,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 +312,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,9 +340,10 @@ class WalletRepo @Inject constructor( } } - is Event.PaymentReceived if lightningRepo.wasCurrentReceiveInvoiceSettled(event.paymentHash) -> { + is Event.PaymentReceived if settledReceiveInvoice?.paymentHash == event.paymentHash -> { + val settledBolt11 = settledReceiveInvoice.bolt11 + if (!invalidateSettledReceiveInvoice(settledBolt11)) return@withContext Logger.debug("refreshBip21ForEvent: $event", context = TAG) - invalidateSettledReceiveInvoice() updateBip21Url() refreshAddressIfNeeded() } @@ -357,11 +364,12 @@ class WalletRepo @Inject constructor( refreshReusableReceiveAddress() } - private suspend fun invalidateSettledReceiveInvoice() { - val settledBolt11 = _walletState.value.bolt11 - _walletState.update { it.copy(bolt11 = "", bip21 = "") } - runSuspendCatching { cacheStore.invalidateReceiveLightningInvoice(expectedBolt11 = settledBolt11) } - .onFailure { Logger.error("Failed to invalidate settled receive invoice", it, context = TAG) } + 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) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0e149f8a43..91eeeecd9c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -65,6 +65,7 @@ import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid +import to.bitkit.App import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore @@ -130,6 +131,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 +263,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 +389,7 @@ class AppViewModel @Inject constructor( } } } + observeReceiveSheetInvoice() observeLdkNodeEvents() observeLightningUsableChannels() observePublicPaykitEndpoints() @@ -418,7 +427,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 +595,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 +621,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,12 +941,16 @@ 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 ( - _currentSheet.value is Sheet.Receive && - lightningRepo.wasCurrentReceiveInvoiceSettled(paymentHash) + receiveSheetToClose != null && + receiveSheetContext === receiveSheetToClose && + _currentSheet.value === receiveSheetToClose.sheet ) { hideSheet() } @@ -998,7 +1031,10 @@ class AppViewModel @Inject constructor( val command = NotifyPaymentReceived.Command.from(event) ?: return val result = notifyPaymentReceivedHandler(command).getOrNull() if (result !is NotifyPaymentReceived.Result.ShowSheet) return - showTransactionSheet(result.sheet) + if (App.currentActivity?.value == null) return + notifyPaymentReceivedHandler.markPresented(command).onSuccess { + showTransactionSheet(result.sheet) + } } private fun notifyTransactionUnconfirmed() = toast( @@ -2830,16 +2866,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 201a436724..959d5f8398 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -154,6 +154,7 @@ class LightningNodeServiceTest : BaseUnitTest() { ) whenever(notifyPaymentReceivedHandler.invoke(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification))) + whenever { notifyPaymentReceivedHandler.markPresented(any()) }.thenReturn(Result.success(Unit)) // Default: not a CJIT channel ready unless a test overrides it whenever(notifyChannelReadyHandler.invoke(any())) @@ -306,10 +307,11 @@ class LightningNodeServiceTest : BaseUnitTest() { sats = 100L, ) verify(cacheStore).setBackgroundReceive(expected) + verify(notifyPaymentReceivedHandler).markPresented(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,7 +339,85 @@ class LightningNodeServiceTest : BaseUnitTest() { assertNull(paymentNotification, "Payment notification should NOT be present in foreground") verify(cacheStore, never()).setBackgroundReceive(any()) - verify(notifyPaymentReceivedHandler, never()).invoke(any()) + verify(notifyPaymentReceivedHandler).invoke(any()) + verify(notifyPaymentReceivedHandler, never()).markPresented(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, never()).markPresented(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).markPresented(any()) + verify(cacheStore).setBackgroundReceive(any()) } @Test 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..262e0c401b 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -85,6 +85,11 @@ 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 marked = sut.markPresented(command) + + assertTrue(marked.isSuccess) verify(activityRepo).markActivityAsSeen("paymentId123") } @@ -110,6 +115,12 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals("hash123", paymentResult.sheet.paymentHashOrTxId) assertNotNull(paymentResult.notification) assertEquals("Payment Received", paymentResult.notification.title) + verify(activityRepo, never()).markActivityAsSeen(any()) + + val marked = sut.markPresented(command) + + assertTrue(marked.isSuccess) + verify(activityRepo).markActivityAsSeen("paymentId123") } @Test @@ -133,6 +144,11 @@ 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 marked = sut.markPresented(command) + + assertTrue(marked.isSuccess) verify(activityRepo).markOnchainActivityAsSeen("txid456") } @@ -168,6 +184,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val command = NotifyPaymentReceived.Command.Onchain(event = event) sut(command) + sut.markPresented(command) inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 03f3c338e8..1b88289b08 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -216,18 +216,22 @@ class LightningRepoTest : BaseUnitTest() { amountMsat = 1_000uL, customRecords = emptyList(), ) - val publishedEvent = async { sut.nodeEvents.first() } + val publishedUpdate = async { sut.nodeEventUpdates.first() } val handling = async { eventHandler(event) } processingStarted.await() verify(cacheStore).invalidateReceiveLightningInvoice(bolt11) - assertTrue(sut.wasCurrentReceiveInvoiceSettled(event.paymentHash)) - assertFalse(publishedEvent.isCompleted) + assertFalse(publishedUpdate.isCompleted) resumeProcessing.complete(Unit) handling.await() - assertEquals(event, publishedEvent.await()) + val update = publishedUpdate.await() + assertEquals(event, update.event) + assertEquals( + SettledReceiveInvoice(bolt11 = bolt11, paymentHash = event.paymentHash), + update.settledReceiveInvoice, + ) } @Test @@ -245,10 +249,43 @@ class LightningRepoTest : BaseUnitTest() { customRecords = emptyList(), ) + val publishedUpdate = async { sut.nodeEventUpdates.first() } + eventHandler(event) verify(cacheStore, never()).invalidateReceiveLightningInvoice(anyOrNull()) - assertFalse(sut.wasCurrentReceiveInvoiceSettled(event.paymentHash)) + 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))) + whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11))) + 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") } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index da69fd6546..db4efdd99a 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -90,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)) @@ -632,9 +632,8 @@ 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) - whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("testPaymentHash")).thenReturn(true) sut = createSut() sut.loadFromCache() @@ -644,7 +643,8 @@ class WalletRepoTest : BaseUnitTest() { paymentHash = "testPaymentHash", amountMsat = 1000uL, customRecords = emptyList() - ) + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), ) verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() @@ -652,9 +652,8 @@ 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) - whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("testPaymentHash")).thenReturn(true) sut = createSut() sut.loadFromCache() @@ -664,7 +663,8 @@ class WalletRepoTest : BaseUnitTest() { paymentHash = "testPaymentHash", amountMsat = 1000uL, customRecords = emptyList() - ) + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), ) verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() @@ -673,7 +673,6 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21ForEvent PaymentReceived should invalidate the settled invoice`() = test { whenever(lightningRepo.canReceive()).thenReturn(true) - whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("testPaymentHash")).thenReturn(true) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) .thenReturn(Result.success(INVOICE_REPLACEMENT)) sut.setOnchainAddress(ADDRESS) @@ -686,13 +685,14 @@ class WalletRepoTest : BaseUnitTest() { paymentHash = "testPaymentHash", amountMsat = 1000uL, customRecords = emptyList() - ) + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), ) assertEquals("", sut.walletState.value.bolt11) assertFalse(sut.walletState.value.bip21.contains(INVOICE)) assertTrue(sut.walletState.value.bip21.contains(ADDRESS)) - verify(cacheStore).invalidateReceiveLightningInvoice(INVOICE) + verify(cacheStore, never()).invalidateReceiveLightningInvoice(anyOrNull()) verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) verify(preActivityMetadataRepo, never()).addPreActivityMetadata(any()) } @@ -717,10 +717,30 @@ class WalletRepoTest : BaseUnitTest() { verify(cacheStore, never()).invalidateReceiveLightningInvoice(anyOrNull()) } + @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, "testPaymentHash"), + ) + + 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.wasCurrentReceiveInvoiceSettled("testPaymentHash")).thenReturn(true) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) .thenReturn(Result.success(INVOICE_REPLACEMENT)) sut.setOnchainAddress(ADDRESS) @@ -733,7 +753,8 @@ class WalletRepoTest : BaseUnitTest() { paymentHash = "testPaymentHash", amountMsat = 1000uL, customRecords = emptyList() - ) + ), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), ) assertEquals("", sut.walletState.value.bolt11) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d9cadd7c42..d508360c8e 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,12 +30,15 @@ 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 @@ -42,6 +49,9 @@ 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 @@ -58,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 @@ -66,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 @@ -138,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) @@ -152,6 +164,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut = createViewModel() } + @After + fun tearDown() { + App.currentActivity = null + } + @Suppress("LongMethod") private fun stubRepositories() { whenever(context.getString(any())).thenReturn("") @@ -159,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() @@ -272,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() @@ -855,7 +886,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() setPendingContactPaymentContext(paymentHash, contactKey) - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -877,7 +908,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() setPendingContactPaymentContext(paymentHash, "pubkycontact") - nodeEvents.emit( + emitNodeEvent( Event.PaymentFailed( paymentId = "payment_id", paymentHash = paymentHash, @@ -906,7 +937,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.showSheet(Sheet.Send()) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentFailed( paymentId = "payment_id", paymentHash = paymentHash, @@ -919,18 +950,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `received lightning payment closes the active receive sheet`() = test { - whenever(lightningRepo.wasCurrentReceiveInvoiceSettled("payment-hash")).thenReturn(true) + 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() - nodeEvents.emit( + emitNodeEvent( Event.PaymentReceived( paymentId = "payment-id", paymentHash = "payment-hash", amountMsat = 1_000uL, customRecords = emptyList(), ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice", "payment-hash"), ) advanceUntilIdle() @@ -945,7 +979,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.showSheet(sheet) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentReceived( paymentId = "payment-id", paymentHash = "unrelated-payment-hash", @@ -962,22 +996,143 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @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() - nodeEvents.emit( + emitNodeEvent( Event.PaymentReceived( paymentId = "payment-id", paymentHash = "payment-hash", amountMsat = 1_000uL, customRecords = emptyList(), ), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice", "payment-hash"), ) advanceUntilIdle() assertEquals(sheet, sut.currentSheet.value) } + @Test + fun `received lightning payment is claimed by the UI only 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.markPresented(any()) }.thenReturn(Result.success(Unit)) + App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler).markPresented(any()) + assertEquals(details, sut.transactionSheet.value) + } + + @Test + fun `received lightning payment is left for the service while backgrounded`() = 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.markPresented(any()) }.thenReturn(Result.success(Unit)) + App.currentActivity = CurrentActivity() + + emitNodeEvent( + Event.PaymentReceived( + paymentId = "payment-id", + paymentHash = "payment-hash", + amountMsat = 1_000uL, + customRecords = emptyList(), + ), + ) + advanceUntilIdle() + + verify(notifyPaymentReceivedHandler, never()).markPresented(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", "payment-hash"), + ) + 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", "payment-hash"), + ) + 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" @@ -1154,7 +1309,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -1231,7 +1386,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.PayConfirmed) advanceUntilIdle() - nodeEvents.emit( + emitNodeEvent( Event.PaymentSuccessful( paymentId = "payment_id", paymentHash = paymentHash, @@ -1250,7 +1405,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() clearInvocations(publicPaykitRepo) - nodeEvents.emit( + emitNodeEvent( Event.ChannelReady( channelId = "testChannelId", userChannelId = "testUserChannelId", @@ -1270,7 +1425,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() clearInvocations(publicPaykitRepo) - nodeEvents.emit( + emitNodeEvent( Event.ChannelClosed( channelId = "testChannelId", userChannelId = "testUserChannelId", From 2a3c2584697299ee732d582cc1591f6fbf670399 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 15:00:12 +0200 Subject: [PATCH 6/7] fix: harden receive settlement handling --- .../androidServices/LightningNodeService.kt | 5 +- .../main/java/to/bitkit/data/CacheStore.kt | 9 ++-- .../commands/NotifyPaymentReceivedHandler.kt | 24 +++++++++- .../to/bitkit/repositories/LightningRepo.kt | 13 +++--- .../java/to/bitkit/repositories/WalletRepo.kt | 10 +++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 7 +-- .../LightningNodeServiceTest.kt | 10 ++-- .../java/to/bitkit/data/AppCacheDataTest.kt | 2 + .../NotifyPaymentReceivedHandlerTest.kt | 46 ++++++++++++++++--- .../bitkit/repositories/LightningRepoTest.kt | 19 +++++--- .../to/bitkit/repositories/WalletRepoTest.kt | 2 +- .../viewmodels/AppViewModelSendFlowTest.kt | 39 +++++++++++++--- 12 files changed, 140 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 33bf067619..66395b27ce 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -111,9 +111,8 @@ class LightningNodeService : Service() { Logger.debug("Payment notification result: $result", context = TAG) if (result !is NotifyPaymentReceived.Result.ShowNotification) return if (App.currentActivity?.value != null) return - notifyPaymentReceivedHandler.markPresented(command).onSuccess { - showPaymentNotification(result.sheet, result.notification) - } + if (!notifyPaymentReceivedHandler.claimPresentation(command)) return + showPaymentNotification(result.sheet, result.notification) } } diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 5d68537e0a..fe5e9e7248 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -55,8 +55,8 @@ 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) { @@ -134,6 +134,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(), @@ -143,7 +144,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 = "") + 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 7e632ca6b5..d343ef4070 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -2,6 +2,8 @@ package to.bitkit.domain.commands import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching @@ -20,10 +22,15 @@ class NotifyPaymentReceivedHandler @Inject constructor( private val activityRepo: ActivityRepo, private val receivedNotificationContent: ReceivedNotificationContent, ) { + private val presentationClaimsMutex = Mutex() + private val presentationClaims = mutableSetOf() + suspend operator fun invoke( command: NotifyPaymentReceived.Command, ): Result = withContext(ioDispatcher) { 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) @@ -44,9 +51,24 @@ class NotifyPaymentReceivedHandler @Inject constructor( } } - suspend fun markPresented(command: NotifyPaymentReceived.Command): Result = withContext(ioDispatcher) { + suspend fun claimPresentation(command: NotifyPaymentReceived.Command): Boolean = withContext(ioDispatcher) { + val key = presentationKey(command) ?: return@withContext false + val claimed = presentationClaimsMutex.withLock { presentationClaims.add(key) } + if (!claimed) return@withContext false + runSuspendCatching { markAsSeen(command) } .onFailure { Logger.error("Failed to mark payment notification as presented", it, context = TAG) } + true + } + + private suspend fun isPresentationClaimed(command: NotifyPaymentReceived.Command): Boolean { + val key = presentationKey(command) ?: return false + return presentationClaimsMutex.withLock { 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 { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index cb77a3711b..976f66302a 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 @@ -66,7 +67,6 @@ import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching -import to.bitkit.ext.toHex import to.bitkit.ext.toPeerDetailsList import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS @@ -463,17 +463,18 @@ class LightningRepo @Inject constructor( } private suspend fun invalidateSettledReceiveInvoice(event: Event.PaymentReceived): SettledReceiveInvoice? { - val cachedBolt11 = runSuspendCatching { - cacheStore.data.first().bolt11 + 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 = runSuspendCatching { - (coreService.decode(cachedBolt11) as? Scanner.Lightning)?.invoice?.paymentHash?.toHex() + val cachedPaymentHash = cachedReceive.bolt11PaymentHash.takeIf { it.isNotEmpty() } ?: runCatching { + Bolt11Invoice.fromStr(cachedBolt11).paymentHash() }.onFailure { - Logger.error("Failed to decode cached receive invoice", it, context = TAG) + Logger.error("Failed to parse cached receive invoice", it, context = TAG) }.getOrNull() if (cachedPaymentHash != event.paymentHash) return null diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index add402dac7..3a8b7c46e6 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 @@ -577,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 91eeeecd9c..6b1e1a69d6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -65,7 +65,6 @@ import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.Txid -import to.bitkit.App import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore @@ -1031,10 +1030,8 @@ class AppViewModel @Inject constructor( val command = NotifyPaymentReceived.Command.from(event) ?: return val result = notifyPaymentReceivedHandler(command).getOrNull() if (result !is NotifyPaymentReceived.Result.ShowSheet) return - if (App.currentActivity?.value == null) return - notifyPaymentReceivedHandler.markPresented(command).onSuccess { - showTransactionSheet(result.sheet) - } + if (!notifyPaymentReceivedHandler.claimPresentation(command)) return + showTransactionSheet(result.sheet) } private fun notifyTransactionUnconfirmed() = toast( diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 959d5f8398..01f171e636 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -154,7 +154,7 @@ class LightningNodeServiceTest : BaseUnitTest() { ) whenever(notifyPaymentReceivedHandler.invoke(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification))) - whenever { notifyPaymentReceivedHandler.markPresented(any()) }.thenReturn(Result.success(Unit)) + whenever { notifyPaymentReceivedHandler.claimPresentation(any()) }.thenReturn(true) // Default: not a CJIT channel ready unless a test overrides it whenever(notifyChannelReadyHandler.invoke(any())) @@ -307,7 +307,7 @@ class LightningNodeServiceTest : BaseUnitTest() { sats = 100L, ) verify(cacheStore).setBackgroundReceive(expected) - verify(notifyPaymentReceivedHandler).markPresented(any()) + verify(notifyPaymentReceivedHandler).claimPresentation(any()) } @Test @@ -340,7 +340,7 @@ class LightningNodeServiceTest : BaseUnitTest() { verify(cacheStore, never()).setBackgroundReceive(any()) verify(notifyPaymentReceivedHandler).invoke(any()) - verify(notifyPaymentReceivedHandler, never()).markPresented(any()) + verify(notifyPaymentReceivedHandler, never()).claimPresentation(any()) } @Test @@ -377,7 +377,7 @@ class LightningNodeServiceTest : BaseUnitTest() { it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNull(notification) - verify(notifyPaymentReceivedHandler, never()).markPresented(any()) + verify(notifyPaymentReceivedHandler, never()).claimPresentation(any()) verify(cacheStore, never()).setBackgroundReceive(any()) } @@ -416,7 +416,7 @@ class LightningNodeServiceTest : BaseUnitTest() { it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNotNull(notification) - verify(notifyPaymentReceivedHandler).markPresented(any()) + verify(notifyPaymentReceivedHandler).claimPresentation(any()) verify(cacheStore).setBackgroundReceive(any()) } diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt index 58ad1db63e..c0487f0442 100644 --- a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -10,6 +10,7 @@ class AppCacheDataTest { val cache = AppCacheData( onchainAddress = "bc1qtest", bolt11 = "paidInvoice", + bolt11PaymentHash = "paymentHash", bip21 = "bitcoin:bc1qtest?lightning=paidInvoice", balance = BalanceState(totalOnchainSats = 42uL), paidOrders = mapOf("order" to "txid"), @@ -18,6 +19,7 @@ class AppCacheDataTest { val invalidated = cache.invalidateReceiveLightningInvoice() assertEquals("", invalidated.bolt11) + assertEquals("", invalidated.bolt11PaymentHash) assertEquals("", invalidated.bip21) assertEquals(cache.onchainAddress, invalidated.onchainAddress) assertEquals(cache.balance, invalidated.balance) 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 262e0c401b..e337126eb0 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 @@ -87,9 +88,9 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(1000L, paymentResult.sheet.sats) verify(activityRepo, never()).markActivityAsSeen(any()) - val marked = sut.markPresented(command) + val claimed = sut.claimPresentation(command) - assertTrue(marked.isSuccess) + assertTrue(claimed) verify(activityRepo).markActivityAsSeen("paymentId123") } @@ -117,9 +118,9 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals("Payment Received", paymentResult.notification.title) verify(activityRepo, never()).markActivityAsSeen(any()) - val marked = sut.markPresented(command) + val claimed = sut.claimPresentation(command) - assertTrue(marked.isSuccess) + assertTrue(claimed) verify(activityRepo).markActivityAsSeen("paymentId123") } @@ -146,9 +147,9 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { assertEquals(5000L, paymentResult.sheet.sats) verify(activityRepo, never()).markOnchainActivityAsSeen(any()) - val marked = sut.markPresented(command) + val claimed = sut.claimPresentation(command) - assertTrue(marked.isSuccess) + assertTrue(claimed) verify(activityRepo).markOnchainActivityAsSeen("txid456") } @@ -184,7 +185,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val command = NotifyPaymentReceived.Command.Onchain(event = event) sut(command) - sut.markPresented(command) + sut.claimPresentation(command) inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) @@ -260,4 +261,35 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val paymentResult = result.getOrThrow() assertTrue(paymentResult is NotifyPaymentReceived.Result.Skip) } + + @Test + fun `claimPresentation 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)) + 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()) + } } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 1b88289b08..acfbac2432 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -196,13 +196,15 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `PaymentReceived invalidates the cached invoice before awaiting activity and publishing the event`() = 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))) - whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11))) + 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 { @@ -222,6 +224,7 @@ class LightningRepoTest : BaseUnitTest() { processingStarted.await() verify(cacheStore).invalidateReceiveLightningInvoice(bolt11) + verify(coreService, never()).decode(bolt11) assertFalse(publishedUpdate.isCompleted) resumeProcessing.complete(Unit) @@ -238,8 +241,9 @@ class LightningRepoTest : BaseUnitTest() { 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))) - whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11))) + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(bolt11 = bolt11, bolt11PaymentHash = "010203")) + ) whenever(coreService.activity).thenReturn(activityService) val eventHandler = startNodeAndCaptureEvents() val event = Event.PaymentReceived( @@ -264,8 +268,9 @@ class LightningRepoTest : BaseUnitTest() { 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))) - whenever(coreService.decode(bolt11)).thenReturn(Scanner.Lightning(lightningInvoice(bolt11))) + whenever(cacheStore.data).thenReturn( + flowOf(AppCacheData(bolt11 = bolt11, bolt11PaymentHash = "010203")) + ) whenever( cacheStore.invalidateReceiveLightningInvoice(bolt11) ).thenThrow( diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index db4efdd99a..e46802727f 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -393,7 +393,7 @@ class WalletRepoTest : BaseUnitTest() { sut.setBolt11(INVOICE) assertEquals(INVOICE, sut.walletState.value.bolt11) - verify(cacheStore).saveBolt11(INVOICE) + verify(cacheStore).saveBolt11(INVOICE, "") } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index d508360c8e..17f85f4167 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -1015,7 +1015,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `received lightning payment is claimed by the UI only while foregrounded`() = test { + fun `received lightning payment is claimed by the UI while foregrounded`() = test { val details = NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.RECEIVED, @@ -1024,7 +1024,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) whenever { notifyPaymentReceivedHandler(any()) } .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) - whenever { notifyPaymentReceivedHandler.markPresented(any()) }.thenReturn(Result.success(Unit)) + whenever { notifyPaymentReceivedHandler.claimPresentation(any()) }.thenReturn(true) App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } emitNodeEvent( @@ -1037,12 +1037,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(notifyPaymentReceivedHandler).markPresented(any()) + verify(notifyPaymentReceivedHandler).claimPresentation(any()) assertEquals(details, sut.transactionSheet.value) } @Test - fun `received lightning payment is left for the service while backgrounded`() = test { + fun `received lightning payment uses the UI handoff after the app backgrounds`() = test { val details = NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, direction = NewTransactionSheetDirection.RECEIVED, @@ -1051,7 +1051,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) whenever { notifyPaymentReceivedHandler(any()) } .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) - whenever { notifyPaymentReceivedHandler.markPresented(any()) }.thenReturn(Result.success(Unit)) + whenever { notifyPaymentReceivedHandler.claimPresentation(any()) }.thenReturn(true) App.currentActivity = CurrentActivity() emitNodeEvent( @@ -1064,7 +1064,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) advanceUntilIdle() - verify(notifyPaymentReceivedHandler, never()).markPresented(any()) + verify(notifyPaymentReceivedHandler).claimPresentation(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()) assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) } From 2f968ad535420bb327f0233344183ea85f1e7711 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 16:54:38 +0200 Subject: [PATCH 7/7] fix: harden receive presentation handoff --- .../androidServices/LightningNodeService.kt | 14 ++++-- .../main/java/to/bitkit/data/CacheStore.kt | 10 +++-- .../commands/NotifyPaymentReceivedHandler.kt | 31 ++++++++----- .../to/bitkit/repositories/LightningRepo.kt | 2 - .../java/to/bitkit/repositories/WalletRepo.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 1 + .../LightningNodeServiceTest.kt | 20 ++++++--- .../java/to/bitkit/data/AppCacheDataTest.kt | 24 +++++++++- .../java/to/bitkit/data/CacheStoreTest.kt | 45 +++++++++++++++++++ .../NotifyPaymentReceivedHandlerTest.kt | 20 ++++++++- .../java/to/bitkit/fcm/WakeNodeWorkerTest.kt | 3 -- .../bitkit/repositories/LightningRepoTest.kt | 6 +-- .../to/bitkit/repositories/WalletRepoTest.kt | 22 ++++----- .../viewmodels/AppViewModelSendFlowTest.kt | 27 ++++++----- 14 files changed, 166 insertions(+), 63 deletions(-) create mode 100644 app/src/test/java/to/bitkit/data/CacheStoreTest.kt diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 66395b27ce..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 @@ -108,11 +109,16 @@ class LightningNodeService : Service() { val command = NotifyPaymentReceived.Command.from(event, includeNotification = true) ?: return notifyPaymentReceivedHandler(command).onSuccess { result -> - Logger.debug("Payment notification result: $result", context = TAG) + Logger.debug("Handled payment notification with result '$result'", context = TAG) if (result !is NotifyPaymentReceived.Result.ShowNotification) return - if (App.currentActivity?.value != null) return - if (!notifyPaymentReceivedHandler.claimPresentation(command)) return - showPaymentNotification(result.sheet, result.notification) + 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) } } diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index fe5e9e7248..348122835f 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -63,15 +63,17 @@ class CacheStore @Inject constructor( store.updateData { it.copy(bip21 = bip21) } } - suspend fun invalidateReceiveLightningInvoice(expectedBolt11: String? = null): Boolean { - val updated = store.updateData { - if (expectedBolt11 == null || it.bolt11 == expectedBolt11) { + suspend fun invalidateReceiveLightningInvoice(expectedBolt11: String): Boolean { + var invalidated = false + store.updateData { + if (it.bolt11 == expectedBolt11) { + invalidated = true it.invalidateReceiveLightningInvoice() } else { it } } - return updated.bolt11.isEmpty() + return invalidated } suspend fun cacheBalance(balanceState: BalanceState) { 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 d343ef4070..e3ea82a031 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -2,8 +2,6 @@ package to.bitkit.domain.commands import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher import to.bitkit.ext.runSuspendCatching @@ -22,7 +20,7 @@ class NotifyPaymentReceivedHandler @Inject constructor( private val activityRepo: ActivityRepo, private val receivedNotificationContent: ReceivedNotificationContent, ) { - private val presentationClaimsMutex = Mutex() + private val presentationClaimsLock = Any() private val presentationClaims = mutableSetOf() suspend operator fun invoke( @@ -51,19 +49,28 @@ class NotifyPaymentReceivedHandler @Inject constructor( } } - suspend fun claimPresentation(command: NotifyPaymentReceived.Command): Boolean = withContext(ioDispatcher) { - val key = presentationKey(command) ?: return@withContext false - val claimed = presentationClaimsMutex.withLock { presentationClaims.add(key) } - if (!claimed) return@withContext false + fun claimPresentation(command: NotifyPaymentReceived.Command): Boolean = claimPresentation(command) { true } - runSuspendCatching { markAsSeen(command) } - .onFailure { Logger.error("Failed to mark payment notification as presented", it, context = TAG) } - 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 suspend fun isPresentationClaimed(command: NotifyPaymentReceived.Command): Boolean { + private fun isPresentationClaimed(command: NotifyPaymentReceived.Command): Boolean { val key = presentationKey(command) ?: return false - return presentationClaimsMutex.withLock { key in presentationClaims } + return synchronized(presentationClaimsLock) { key in presentationClaims } } private fun presentationKey(command: NotifyPaymentReceived.Command): String? = when (command) { diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 976f66302a..0599d01ddc 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -487,7 +487,6 @@ class LightningRepo @Inject constructor( return if (invalidated) { SettledReceiveInvoice( bolt11 = cachedBolt11, - paymentHash = event.paymentHash, ) } else { null @@ -1786,7 +1785,6 @@ data class NodeEventUpdate( data class SettledReceiveInvoice( val bolt11: String, - val paymentHash: PaymentHash, ) sealed class ProbeError(message: String) : AppError(message) { diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3a8b7c46e6..87745fae9f 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -341,10 +341,10 @@ class WalletRepo @Inject constructor( } } - is Event.PaymentReceived if settledReceiveInvoice?.paymentHash == event.paymentHash -> { + is Event.PaymentReceived if settledReceiveInvoice != null -> { val settledBolt11 = settledReceiveInvoice.bolt11 if (!invalidateSettledReceiveInvoice(settledBolt11)) return@withContext - Logger.debug("refreshBip21ForEvent: $event", context = TAG) + Logger.debug("Refreshing BIP21 for event '$event'", context = TAG) updateBip21Url() refreshAddressIfNeeded() } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6b1e1a69d6..0b3680add1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1032,6 +1032,7 @@ class AppViewModel @Inject constructor( if (result !is NotifyPaymentReceived.Result.ShowSheet) return if (!notifyPaymentReceivedHandler.claimPresentation(command)) return showTransactionSheet(result.sheet) + notifyPaymentReceivedHandler.recordPresentation(command) } private fun notifyTransactionUnconfirmed() = toast( diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt index 01f171e636..c6a361fb2b 100644 --- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt +++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt @@ -154,7 +154,9 @@ class LightningNodeServiceTest : BaseUnitTest() { ) whenever(notifyPaymentReceivedHandler.invoke(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowNotification(sheet, notification))) - whenever { notifyPaymentReceivedHandler.claimPresentation(any()) }.thenReturn(true) + 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())) @@ -307,7 +309,8 @@ class LightningNodeServiceTest : BaseUnitTest() { sats = 100L, ) verify(cacheStore).setBackgroundReceive(expected) - verify(notifyPaymentReceivedHandler).claimPresentation(any()) + verify(notifyPaymentReceivedHandler).claimPresentation(any(), any()) + verify(notifyPaymentReceivedHandler).recordPresentation(any()) } @Test @@ -340,13 +343,14 @@ class LightningNodeServiceTest : BaseUnitTest() { verify(cacheStore, never()).setBackgroundReceive(any()) verify(notifyPaymentReceivedHandler).invoke(any()) - verify(notifyPaymentReceivedHandler, never()).claimPresentation(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 { + whenever(notifyPaymentReceivedHandler.invoke(any())).doSuspendableAnswer { App.currentActivity?.onActivityStarted(mockActivity) val sheet = NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, @@ -377,7 +381,8 @@ class LightningNodeServiceTest : BaseUnitTest() { it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNull(notification) - verify(notifyPaymentReceivedHandler, never()).claimPresentation(any()) + verify(notifyPaymentReceivedHandler).claimPresentation(any(), any()) + verify(notifyPaymentReceivedHandler, never()).recordPresentation(any()) verify(cacheStore, never()).setBackgroundReceive(any()) } @@ -385,7 +390,7 @@ class LightningNodeServiceTest : BaseUnitTest() { 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 { + whenever(notifyPaymentReceivedHandler.invoke(any())).doSuspendableAnswer { App.currentActivity?.onActivityStopped(mockActivity) val sheet = NewTransactionSheetDetails( type = NewTransactionSheetType.LIGHTNING, @@ -416,7 +421,8 @@ class LightningNodeServiceTest : BaseUnitTest() { it.extras.getString(Notification.EXTRA_TITLE) == context.getString(R.string.notification__received__title) } assertNotNull(notification) - verify(notifyPaymentReceivedHandler).claimPresentation(any()) + verify(notifyPaymentReceivedHandler).claimPresentation(any(), any()) + verify(notifyPaymentReceivedHandler).recordPresentation(any()) verify(cacheStore).setBackgroundReceive(any()) } diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt index c0487f0442..ed4671630d 100644 --- a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -1,10 +1,32 @@ 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 { +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( 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 e337126eb0..ca08e47356 100644 --- a/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt +++ b/app/src/test/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandlerTest.kt @@ -91,6 +91,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val claimed = sut.claimPresentation(command) assertTrue(claimed) + sut.recordPresentation(command) verify(activityRepo).markActivityAsSeen("paymentId123") } @@ -121,6 +122,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val claimed = sut.claimPresentation(command) assertTrue(claimed) + sut.recordPresentation(command) verify(activityRepo).markActivityAsSeen("paymentId123") } @@ -150,6 +152,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { val claimed = sut.claimPresentation(command) assertTrue(claimed) + sut.recordPresentation(command) verify(activityRepo).markOnchainActivityAsSeen("txid456") } @@ -186,6 +189,7 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { sut(command) sut.claimPresentation(command) + sut.recordPresentation(command) inOrder(activityRepo) { verify(activityRepo).handleOnchainTransactionReceived("txid789", details) @@ -263,17 +267,18 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { } @Test - fun `claimPresentation keeps the claim when marking as seen fails`() = 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") } + 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)) } @@ -292,4 +297,15 @@ class NotifyPaymentReceivedHandlerTest : BaseUnitTest() { 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/fcm/WakeNodeWorkerTest.kt b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt index 25cba37a4b..74534dc46f 100644 --- a/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt +++ b/app/src/test/java/to/bitkit/fcm/WakeNodeWorkerTest.kt @@ -109,7 +109,6 @@ class WakeNodeWorkerTest : BaseUnitTest() { assertNull(findNotificationByTitle(receivedTitle), "A non-CJIT channel must not show a payment notification") verify(activityRepo, never()).insertActivityFromCjit(any(), any()) verify(cacheStore, never()).setBackgroundReceive(any()) - verify(cacheStore, never()).invalidateReceiveLightningInvoice() } @Test @@ -175,7 +174,6 @@ class WakeNodeWorkerTest : BaseUnitTest() { val notification = findNotificationByTitle(title) assertNotNull(notification, "Payment notification should be delivered when app is killed") assertEquals(body, notification?.extras?.getString(Notification.EXTRA_TEXT)) - verify(cacheStore, never()).invalidateReceiveLightningInvoice() } @Test @@ -195,7 +193,6 @@ class WakeNodeWorkerTest : BaseUnitTest() { "Notification is deduped when the foreground service handles it", ) // Receive is still cached for the in-app UI to pick up - verify(cacheStore, never()).invalidateReceiveLightningInvoice() verify(cacheStore).setBackgroundReceive(any()) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index acfbac2432..b287e7ef3c 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -207,7 +207,7 @@ class LightningRepoTest : BaseUnitTest() { 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 { + whenever(activityService.handlePaymentEvent("payment-id")).doSuspendableAnswer { processingStarted.complete(Unit) resumeProcessing.await() } @@ -232,7 +232,7 @@ class LightningRepoTest : BaseUnitTest() { val update = publishedUpdate.await() assertEquals(event, update.event) assertEquals( - SettledReceiveInvoice(bolt11 = bolt11, paymentHash = event.paymentHash), + SettledReceiveInvoice(bolt11 = bolt11), update.settledReceiveInvoice, ) } @@ -257,7 +257,7 @@ class LightningRepoTest : BaseUnitTest() { eventHandler(event) - verify(cacheStore, never()).invalidateReceiveLightningInvoice(anyOrNull()) + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) val update = publishedUpdate.await() assertEquals(event, update.event) assertNull(update.settledReceiveInvoice) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e46802727f..57466fc4ae 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -642,9 +642,9 @@ class WalletRepoTest : BaseUnitTest() { paymentId = "testPaymentId", paymentHash = "testPaymentHash", amountMsat = 1000uL, - customRecords = emptyList() + customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), ) verify(privatePaykitAddressReservationRepo).nextReusableReceiveAddress() @@ -662,9 +662,9 @@ class WalletRepoTest : BaseUnitTest() { paymentId = "testPaymentId", paymentHash = "testPaymentHash", amountMsat = 1000uL, - customRecords = emptyList() + customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), ) verify(privatePaykitAddressReservationRepo, never()).nextReusableReceiveAddress() @@ -684,15 +684,15 @@ class WalletRepoTest : BaseUnitTest() { paymentId = "testPaymentId", paymentHash = "testPaymentHash", amountMsat = 1000uL, - customRecords = emptyList() + customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), + 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(anyOrNull()) + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) verify(preActivityMetadataRepo, never()).addPreActivityMetadata(any()) } @@ -714,7 +714,7 @@ class WalletRepoTest : BaseUnitTest() { assertEquals(INVOICE, sut.walletState.value.bolt11) assertTrue(sut.walletState.value.bip21.contains(INVOICE)) - verify(cacheStore, never()).invalidateReceiveLightningInvoice(anyOrNull()) + verify(cacheStore, never()).invalidateReceiveLightningInvoice(any()) } @Test @@ -730,7 +730,7 @@ class WalletRepoTest : BaseUnitTest() { amountMsat = 1000uL, customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), ) assertEquals(INVOICE_REPLACEMENT, sut.walletState.value.bolt11) @@ -752,9 +752,9 @@ class WalletRepoTest : BaseUnitTest() { paymentId = "testPaymentId", paymentHash = "testPaymentHash", amountMsat = 1000uL, - customRecords = emptyList() + customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice(INVOICE, "testPaymentHash"), + settledReceiveInvoice = SettledReceiveInvoice(INVOICE), ) assertEquals("", sut.walletState.value.bolt11) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 17f85f4167..6ce9051b8f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -964,7 +964,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountMsat = 1_000uL, customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice("settled-invoice", "payment-hash"), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), ) advanceUntilIdle() @@ -1007,7 +1007,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountMsat = 1_000uL, customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice("settled-invoice", "payment-hash"), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), ) advanceUntilIdle() @@ -1022,9 +1022,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paymentHashOrTxId = "payment-hash", sats = 1L, ) - whenever { notifyPaymentReceivedHandler(any()) } + whenever(notifyPaymentReceivedHandler(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) - whenever { notifyPaymentReceivedHandler.claimPresentation(any()) }.thenReturn(true) + whenever(notifyPaymentReceivedHandler.claimPresentation(any())).thenReturn(true) App.currentActivity = CurrentActivity().also { it.onActivityStarted(mock()) } emitNodeEvent( @@ -1038,6 +1038,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(notifyPaymentReceivedHandler).claimPresentation(any()) + verify(notifyPaymentReceivedHandler).recordPresentation(any()) assertEquals(details, sut.transactionSheet.value) } @@ -1049,9 +1050,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paymentHashOrTxId = "payment-hash", sats = 1L, ) - whenever { notifyPaymentReceivedHandler(any()) } + whenever(notifyPaymentReceivedHandler(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) - whenever { notifyPaymentReceivedHandler.claimPresentation(any()) }.thenReturn(true) + whenever(notifyPaymentReceivedHandler.claimPresentation(any())).thenReturn(true) App.currentActivity = CurrentActivity() emitNodeEvent( @@ -1065,6 +1066,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(notifyPaymentReceivedHandler).claimPresentation(any()) + verify(notifyPaymentReceivedHandler).recordPresentation(any()) assertEquals(details, sut.transactionSheet.value) } @@ -1076,9 +1078,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { paymentHashOrTxId = "payment-hash", sats = 1L, ) - whenever { notifyPaymentReceivedHandler(any()) } + whenever(notifyPaymentReceivedHandler(any())) .thenReturn(Result.success(NotifyPaymentReceived.Result.ShowSheet(details))) - whenever { notifyPaymentReceivedHandler.claimPresentation(any()) }.thenReturn(false) + whenever(notifyPaymentReceivedHandler.claimPresentation(any())).thenReturn(false) App.currentActivity = CurrentActivity() emitNodeEvent( @@ -1092,6 +1094,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(notifyPaymentReceivedHandler).claimPresentation(any()) + verify(notifyPaymentReceivedHandler, never()).recordPresentation(any()) assertEquals(NewTransactionSheetDetails.EMPTY, sut.transactionSheet.value) } @@ -1099,7 +1102,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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 { + whenever(activityRepo.handlePaymentEvent("payment-hash")).doSuspendableAnswer { processingStarted.complete(Unit) resumeProcessing.await() } @@ -1114,7 +1117,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountMsat = 1_000uL, customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice("settled-invoice", "payment-hash"), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), ) processingStarted.await() @@ -1132,7 +1135,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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 { + whenever(activityRepo.handlePaymentEvent("payment-hash")).doSuspendableAnswer { processingStarted.complete(Unit) resumeProcessing.await() } @@ -1148,7 +1151,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { amountMsat = 1_000uL, customRecords = emptyList(), ), - settledReceiveInvoice = SettledReceiveInvoice("settled-invoice", "payment-hash"), + settledReceiveInvoice = SettledReceiveInvoice("settled-invoice"), ) processingStarted.await()