From 0d06387b84adf71cedf838c338368262c18e5acb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 02:07:38 +0200 Subject: [PATCH 1/7] feat: toast when a pending payment request expires --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 36 +++++++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 41 +++++++++++++++++++ changelog.d/next/1624.added.md | 1 + 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 changelog.d/next/1624.added.md diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 89c3f602d1..2ec9c243a6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -184,6 +184,7 @@ import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestCreation import to.bitkit.repositories.PaykitPaymentRequestDiagnostics +import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId @@ -1374,12 +1375,21 @@ class AppViewModel @Inject constructor( } } viewModelScope.launch { - paykitPaymentRequestRepo.pendingRequests.drop(1).collect(::onPendingPaykitRequestsChanged) + var previous = paykitPaymentRequestRepo.pendingRequests.value + paykitPaymentRequestRepo.pendingRequests.drop(1).collect { requests -> + onPendingPaykitRequestsChanged(requests, previous) + previous = requests + } } } - private suspend fun onPendingPaykitRequestsChanged(requests: List) { + private suspend fun onPendingPaykitRequestsChanged( + requests: List, + previous: List, + ) { + val requestedId = requestedPaymentRequestId.value retainPaymentRequestPresentationState(requests) + toastSilentlyExpiredPaymentRequests(requests, previous, requestedId) val activeRequest = activeIncomingPaymentRequest() val isProtected = isSubmittingPaymentRequest || uncertainOnchainPaymentRequestId == activeRequest?.id || isPreparedContactPayment(synchronized(contactPaymentContextLock) { activeContactPaymentContext }) @@ -1796,6 +1806,28 @@ class AppViewModel @Inject constructor( } } + /** Tells the user when a pending incoming request expires on its own; the one they opened has its own toast. */ + private fun toastSilentlyExpiredPaymentRequests( + requests: List, + previous: List, + requestedId: PaykitPaymentRequestId?, + ) { + if (!isPaykitEnabled.value) return + val remainingIds = requests.mapTo(mutableSetOf()) { it.id } + val expired = previous.filter { + it.id !in remainingIds && it.id != requestedId && + it.direction == PaykitPaymentRequestDirection.Incoming && it.billingPeriod == null && + !it.isPaymentInFlight && paykitPaymentRequestRepo.isExpired(it) + } + if (expired.isEmpty()) return + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.wallet__payment_request_expired), + testTag = "PaymentRequestExpiredToast", + ) + } + private fun finishExpiredPaymentRequestPresentation(request: PaykitPaymentRequest) { paykitPaymentRequestDiagnostics.logPresentationRejection( request.counterparty, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 94257e20d6..4844e4d1e1 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2663,6 +2663,47 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `pending request expiring on its own shows the expired toast`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + clearInvocations(toastManager) + + whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) + assertEquals("The payment request has expired.", toastCaptor.lastValue.description) + } + + @Test + fun `pending request removed without expiring shows no expired toast`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + clearInvocations(toastManager) + + whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(false) + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + + verify(toastManager, never()).enqueue(any()) + } + @Test fun `expired explicit request shows the expired toast once`() = test { sut.setIsAuthenticated(true) diff --git a/changelog.d/next/1624.added.md b/changelog.d/next/1624.added.md new file mode 100644 index 0000000000..2deca8c892 --- /dev/null +++ b/changelog.d/next/1624.added.md @@ -0,0 +1 @@ +Bitkit now shows a toast when a pending payment request expires. From 493b7dee160edd110879e76262821e0a81853b4f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 02:29:07 +0200 Subject: [PATCH 2/7] fix: toast only for unopened requests the expiry timer removes --- .../repositories/PaykitPaymentRequestRepo.kt | 17 ++++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 38 ++++++------------- .../PaykitPaymentRequestRepoTest.kt | 18 +++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 22 ++++++++++- journeys/payment-requests/expiry-toast.xml | 16 ++++++++ 5 files changed, 81 insertions(+), 30 deletions(-) create mode 100644 journeys/payment-requests/expiry-toast.xml diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 430448a937..4bf78476b0 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -22,8 +22,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update @@ -353,6 +356,11 @@ class PaykitPaymentRequestRepo @Inject constructor( private val targetWriteTickets = mutableMapOf() private val _pendingRequests = MutableStateFlow>(emptyList()) val pendingRequests: StateFlow> = _pendingRequests.asStateFlow() + + private val _expiredProposals = MutableSharedFlow>(extraBufferCapacity = 8) + + /** Incoming one-time requests that expired before the user accepted or paid them. */ + val expiredProposals: SharedFlow> = _expiredProposals.asSharedFlow() private val _paymentRequestHistory = MutableStateFlow>(emptyList()) val paymentRequestHistory: StateFlow> = _paymentRequestHistory.asStateFlow() private val _subscriptions = MutableStateFlow>(emptyList()) @@ -1551,7 +1559,7 @@ class PaykitPaymentRequestRepo @Inject constructor( try { operationMutex.withLock { if (request.isExpired(clock.now())) { - discardExpiredRequestsLocked() + discardExpiredRequestsLocked(handledRequestId = request.id) throw PaykitPaymentRequestError.RequestExpired } val current = _pendingRequests.value.firstOrNull { it.id == request.id } @@ -1734,10 +1742,15 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - private suspend fun discardExpiredRequestsLocked() { + private suspend fun discardExpiredRequestsLocked(handledRequestId: PaykitPaymentRequestId? = null) { val now = clock.now() val subscriptionNow = subscriptionClock.now() + val expiredProposals = _pendingRequests.value.filter { + it.id != handledRequestId && it.direction == PaykitPaymentRequestDirection.Incoming && + it.billingPeriod == null && !it.isPaymentInFlight && it.isProposalExpired(now) + } _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } + if (expiredProposals.isNotEmpty()) _expiredProposals.tryEmit(expiredProposals) _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(now) } _subscriptions.update { subscriptions -> subscriptions.map { it.withExpiredLifecycle(subscriptionNow) } } prunePresentedRequestIds(_pendingRequests.value) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 2ec9c243a6..24218e96eb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -184,7 +184,6 @@ import to.bitkit.repositories.PaykitPaymentProofRepo import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestCreation import to.bitkit.repositories.PaykitPaymentRequestDiagnostics -import to.bitkit.repositories.PaykitPaymentRequestDirection import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestError import to.bitkit.repositories.PaykitPaymentRequestId @@ -506,6 +505,7 @@ class AppViewModel @Inject constructor( private var paykitAmountConversion = PaykitAmountConversion() private val pendingContactPaymentContexts = mutableMapOf() private val _requestedPaymentRequestId = MutableStateFlow(null) + private val expiredFeedbackRequestIds = mutableSetOf() val requestedPaymentRequestId = _requestedPaymentRequestId.asStateFlow() private val paymentRequestPreparation = MutableStateFlow(null) private val dismissedPreparingRequestIds = mutableSetOf() @@ -1375,21 +1375,15 @@ class AppViewModel @Inject constructor( } } viewModelScope.launch { - var previous = paykitPaymentRequestRepo.pendingRequests.value - paykitPaymentRequestRepo.pendingRequests.drop(1).collect { requests -> - onPendingPaykitRequestsChanged(requests, previous) - previous = requests - } + paykitPaymentRequestRepo.pendingRequests.drop(1).collect(::onPendingPaykitRequestsChanged) + } + viewModelScope.launch { + paykitPaymentRequestRepo.expiredProposals.collect(::toastExpiredPaymentRequests) } } - private suspend fun onPendingPaykitRequestsChanged( - requests: List, - previous: List, - ) { - val requestedId = requestedPaymentRequestId.value + private suspend fun onPendingPaykitRequestsChanged(requests: List) { retainPaymentRequestPresentationState(requests) - toastSilentlyExpiredPaymentRequests(requests, previous, requestedId) val activeRequest = activeIncomingPaymentRequest() val isProtected = isSubmittingPaymentRequest || uncertainOnchainPaymentRequestId == activeRequest?.id || isPreparedContactPayment(synchronized(contactPaymentContextLock) { activeContactPaymentContext }) @@ -1806,20 +1800,11 @@ class AppViewModel @Inject constructor( } } - /** Tells the user when a pending incoming request expires on its own; the one they opened has its own toast. */ - private fun toastSilentlyExpiredPaymentRequests( - requests: List, - previous: List, - requestedId: PaykitPaymentRequestId?, - ) { - if (!isPaykitEnabled.value) return - val remainingIds = requests.mapTo(mutableSetOf()) { it.id } - val expired = previous.filter { - it.id !in remainingIds && it.id != requestedId && - it.direction == PaykitPaymentRequestDirection.Incoming && it.billingPeriod == null && - !it.isPaymentInFlight && paykitPaymentRequestRepo.isExpired(it) - } - if (expired.isEmpty()) return + /** Tells the user when a pending incoming request expires unopened; an opened one has its own toast. */ + private fun toastExpiredPaymentRequests(expired: List) { + if (!isPaykitEnabled.value || paykitPaymentRequestRepo.isPaymentSubmissionActive.value) return + val requestedId = requestedPaymentRequestId.value + if (expired.none { it.id != requestedId && it.id !in expiredFeedbackRequestIds }) return toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__payment_request), @@ -1843,6 +1828,7 @@ class AppViewModel @Inject constructor( } clearPaymentRequestPresentationRetry(request.id) if (!showExpiredToast) return + expiredFeedbackRequestIds += request.id toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__payment_request), diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 6a89a8adb2..626c917c22 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent @@ -1294,6 +1295,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `expiry timer reports the incoming proposal that expired`() = test { + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( + listOf(paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString())), + ) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val reported = mutableListOf>() + backgroundScope.launch { sut.expiredProposals.collect { reported += it } } + runCurrent() + + advanceTimeBy(10_000) + runCurrent() + + assertEquals(listOf(listOf(request.id)), reported.map { list -> list.map { it.id } }) + } + @Test fun `outgoing request moves to expired history exactly when it expires`() = test { whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 4844e4d1e1..a35e6bc7e3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -315,6 +315,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyContactsLoadCompletionVersion = MutableStateFlow(0L) private val pubkyContactImportFailure = MutableStateFlow(null) private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) + private val expiredPaykitProposals = MutableSharedFlow>(extraBufferCapacity = 8) private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) private val paykitSubscriptions = MutableStateFlow>(emptyList()) private val onchainPaymentResolutions = MutableStateFlow>(emptyList()) @@ -667,6 +668,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCatching { beforeSend() } } whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) + whenever(paykitPaymentRequestRepo.expiredProposals).thenReturn(expiredPaykitProposals) whenever(paykitPaymentRequestRepo.paymentRequestHistory).thenReturn(paykitPaymentRequestHistory) whenever(paykitPaymentRequestRepo.subscriptions).thenReturn(paykitSubscriptions) whenever(paykitPaymentRequestRepo.automaticSubscriptionProposals()).thenReturn(emptyList()) @@ -2677,8 +2679,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() clearInvocations(toastManager) - whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) pendingPaykitPaymentRequests.value = emptyList() + expiredPaykitProposals.emit(listOf(request)) runCurrent() val toastCaptor = argumentCaptor() @@ -2697,13 +2699,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() clearInvocations(toastManager) - whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(false) + whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) pendingPaykitPaymentRequests.value = emptyList() runCurrent() verify(toastManager, never()).enqueue(any()) } + @Test + fun `expired proposal is not reported while a payment is being submitted`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + paymentSubmissionActive.value = true + expiredPaykitProposals.emit(listOf(request)) + runCurrent() + + verify(toastManager, never()).enqueue(any()) + } + @Test fun `expired explicit request shows the expired toast once`() = test { sut.setIsAuthenticated(true) diff --git a/journeys/payment-requests/expiry-toast.xml b/journeys/payment-requests/expiry-toast.xml new file mode 100644 index 0000000000..b0addd80b4 --- /dev/null +++ b/journeys/payment-requests/expiry-toast.xml @@ -0,0 +1,16 @@ + + + Verifies that a pending incoming one-time Payment Request that expires unopened shows the expired toast once, and that a request the user opened is not reported twice. Uses the linked fixture issuer from README.md, which can send a request with a short proposal expiry. Toasts do not reach the layout dump, so capture a screenshot right after the expiry. + + + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked + Have the issuer send a proposed one-time request for 0.001 btc with a proposal expiry 60 seconds from now + Dismiss the automatic Payment Request confirmation with Back and stay on the home screen without opening the request + Verify the pending Payment Requests bell (testTag "PaymentRequestsBell") is visible + Wait until the proposal expiry passes + Verify from a screenshot that a toast titled "Payment Request" with "The payment request has expired." appears, and that the bell (testTag "PaymentRequestsBell") is gone + Verify no second expired toast appears within the next 10 seconds + Have the issuer send another one-time request with a proposal expiry 60 seconds from now, dismiss the automatic confirmation, then tap the bell and open the request row to its details + Wait on Payment Request details until the expiry passes and verify at most one expired toast appears + + From 85ff0d1a47d8363ae5bd25783c64fe3d2ecd570e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 11:01:42 +0200 Subject: [PATCH 3/7] fix: toast when an unopened request passes its payment deadline --- .../java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 4bf78476b0..9105fd4ce5 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1747,7 +1747,8 @@ class PaykitPaymentRequestRepo @Inject constructor( val subscriptionNow = subscriptionClock.now() val expiredProposals = _pendingRequests.value.filter { it.id != handledRequestId && it.direction == PaykitPaymentRequestDirection.Incoming && - it.billingPeriod == null && !it.isPaymentInFlight && it.isProposalExpired(now) + it.billingPeriod == null && !it.isPaymentInFlight && + it.lifecycleState == PaymentRequestLifecycleState.PROPOSED && it.isExpired(now) } _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } if (expiredProposals.isNotEmpty()) _expiredProposals.tryEmit(expiredProposals) From 47aa0b871e2ca9d18faf6cc36d321e56b9da68c9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 15:09:45 +0200 Subject: [PATCH 4/7] fix: toast for a request a refresh drops after expiry --- .../repositories/PaykitPaymentRequestRepo.kt | 21 +++++++++++++---- .../PaykitPaymentRequestRepoTest.kt | 23 ++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 9105fd4ce5..c57994edc2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1395,7 +1395,7 @@ class PaykitPaymentRequestRepo @Inject constructor( throw PaykitPaymentRequestError.RequestUnavailable } receivedContacts = ReceivedContactsSnapshot(generation, expectedIdentity, contacts) - _pendingRequests.update { incoming } + replacePendingRequests(incoming) _paymentRequestHistory.update { history } _subscriptions.update { subscriptions } prunePresentedSubscriptionProposalIds(subscriptions) @@ -1742,14 +1742,25 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - private suspend fun discardExpiredRequestsLocked(handledRequestId: PaykitPaymentRequestId? = null) { - val now = clock.now() - val subscriptionNow = subscriptionClock.now() - val expiredProposals = _pendingRequests.value.filter { + /** A refresh drops expired proposals while parsing, so report them from the list it replaces. */ + private fun replacePendingRequests(incoming: List) { + val expired = expiredProposals(clock.now()) + _pendingRequests.update { incoming } + if (expired.isNotEmpty()) _expiredProposals.tryEmit(expired) + } + + /** Pending incoming one-time proposals that expired before the user accepted, opened or paid them. */ + private fun expiredProposals(now: Instant, handledRequestId: PaykitPaymentRequestId? = null) = + _pendingRequests.value.filter { it.id != handledRequestId && it.direction == PaykitPaymentRequestDirection.Incoming && it.billingPeriod == null && !it.isPaymentInFlight && it.lifecycleState == PaymentRequestLifecycleState.PROPOSED && it.isExpired(now) } + + private suspend fun discardExpiredRequestsLocked(handledRequestId: PaykitPaymentRequestId? = null) { + val now = clock.now() + val subscriptionNow = subscriptionClock.now() + val expiredProposals = expiredProposals(now, handledRequestId) _pendingRequests.update { requests -> requests.filterNot { it.isExpired(now) } } if (expiredProposals.isNotEmpty()) _expiredProposals.tryEmit(expiredProposals) _paymentRequestHistory.update { requests -> requests.withExpiredLifecycle(now) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 626c917c22..fb9e760bef 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -243,9 +243,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { private val usdtPayments = mock() private val subscriptionNotificationScheduler = mock() private var schedulerOriginMillis = 0L + + /** Wall-clock jump on top of scheduler time, as after a suspend, which no scheduled job sees. */ + private var clockJumpMillis = 0L private val clock = object : Clock { override fun now(): Instant = START_TIME.plus( - (testDispatcher.scheduler.currentTime - schedulerOriginMillis).milliseconds, + (testDispatcher.scheduler.currentTime - schedulerOriginMillis + clockJumpMillis).milliseconds, ) } private lateinit var sut: PaykitPaymentRequestRepo @@ -1295,6 +1298,24 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `refresh after a clock jump past expiry reports the expired proposal`() = test { + val record = paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() + val reported = mutableListOf>() + backgroundScope.launch { sut.expiredProposals.collect { reported += it } } + runCurrent() + + clockJumpMillis = 10_000 + sut.refresh().getOrThrow() + runCurrent() + + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(listOf(listOf(request.id)), reported.map { list -> list.map { it.id } }) + } + @Test fun `expiry timer reports the incoming proposal that expired`() = test { whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( From e775d63b66b33f7d3f667b285a7622f2d6c8697d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 16:33:52 +0200 Subject: [PATCH 5/7] fix: one expiry toast per request, even during other payments --- .../repositories/PaykitPaymentRequestRepo.kt | 6 +-- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 +- .../PaykitPaymentRequestRepoTest.kt | 17 ++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 41 +++++++++++++++++-- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index c57994edc2..000f9e57bb 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -1742,14 +1742,14 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - /** A refresh drops expired proposals while parsing, so report them from the list it replaces. */ private fun replacePendingRequests(incoming: List) { - val expired = expiredProposals(clock.now()) + // Parsing already dropped expired proposals; report only those, not ones the snapshot keeps. + val keptIds = incoming.mapTo(mutableSetOf()) { it.id } + val expired = expiredProposals(clock.now()).filterNot { it.id in keptIds } _pendingRequests.update { incoming } if (expired.isNotEmpty()) _expiredProposals.tryEmit(expired) } - /** Pending incoming one-time proposals that expired before the user accepted, opened or paid them. */ private fun expiredProposals(now: Instant, handledRequestId: PaykitPaymentRequestId? = null) = _pendingRequests.value.filter { it.id != handledRequestId && it.direction == PaykitPaymentRequestDirection.Incoming && diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 24218e96eb..f6aff59ba0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1800,9 +1800,8 @@ class AppViewModel @Inject constructor( } } - /** Tells the user when a pending incoming request expires unopened; an opened one has its own toast. */ private fun toastExpiredPaymentRequests(expired: List) { - if (!isPaykitEnabled.value || paykitPaymentRequestRepo.isPaymentSubmissionActive.value) return + if (!isPaykitEnabled.value) return val requestedId = requestedPaymentRequestId.value if (expired.none { it.id != requestedId && it.id !in expiredFeedbackRequestIds }) return toast( diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index fb9e760bef..5aafc3c357 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -1316,6 +1316,23 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertEquals(listOf(listOf(request.id)), reported.map { list -> list.map { it.id } }) } + @Test + fun `refresh does not report a proposal the snapshot still keeps`() = test { + val record = paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) + sut.refresh().getOrThrow() + val reported = mutableListOf>() + backgroundScope.launch { sut.expiredProposals.collect { reported += it } } + runCurrent() + + clockJumpMillis = 5_000 + sut.refresh().getOrThrow() + runCurrent() + + assertEquals(1, sut.pendingRequests.value.size) + assertTrue(reported.isEmpty()) + } + @Test fun `expiry timer reports the incoming proposal that expired`() = test { whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a35e6bc7e3..dd80d66cc1 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2690,7 +2690,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `pending request removed without expiring shows no expired toast`() = test { + fun `pending request removed without the expiry signal shows no expired toast`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() enablePaykitUi() @@ -2707,9 +2707,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `expired proposal is not reported while a payment is being submitted`() = test { + fun `unrelated request expiring during a payment submission still shows the expired toast`() = test { sut.setIsAuthenticated(true) val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) enablePaykitUi() pubkyPublicKey.value = testPublicKey runCurrent() @@ -2719,7 +2723,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() { expiredPaykitProposals.emit(listOf(request)) runCurrent() - verify(toastManager, never()).enqueue(any()) + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) + } + + @Test + fun `opened request reported by both expiry signals shows one expired toast`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.failure(PaykitPaymentRequestError.RequestExpired) + ) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + whenever(paykitPaymentRequestRepo.isExpired(request)).thenReturn(true) + pendingPaykitPaymentRequests.value = emptyList() + expiredPaykitProposals.emit(listOf(request)) + runCurrent() + + verify(toastManager, times(1)).enqueue(any()) } @Test From 503d4f7418a5dca891aee753eb73b383fa603f14 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 16:43:30 +0200 Subject: [PATCH 6/7] fix: toast each expired request once --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 ++++- .../viewmodels/AppViewModelSendFlowTest.kt | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f6aff59ba0..e41f7c1fe2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1803,7 +1803,10 @@ class AppViewModel @Inject constructor( private fun toastExpiredPaymentRequests(expired: List) { if (!isPaykitEnabled.value) return val requestedId = requestedPaymentRequestId.value - if (expired.none { it.id != requestedId && it.id !in expiredFeedbackRequestIds }) return + val newlyExpired = expired.filter { it.id != requestedId && it.id !in expiredFeedbackRequestIds } + if (newlyExpired.isEmpty()) return + // A refresh that started before the expiry can restore a request, so toast each one once. + expiredFeedbackRequestIds += newlyExpired.map { it.id } toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.wallet__payment_request), diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index dd80d66cc1..f45005e775 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2728,6 +2728,27 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals("PaymentRequestExpiredToast", toastCaptor.lastValue.testTag) } + @Test + fun `request reported expired twice shows one expired toast`() = test { + sut.setIsAuthenticated(true) + val request = paymentRequest() + whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( + "The payment request has expired." + ) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + clearInvocations(toastManager) + + expiredPaykitProposals.emit(listOf(request)) + runCurrent() + expiredPaykitProposals.emit(listOf(request)) + runCurrent() + + verify(toastManager, times(1)).enqueue(any()) + } + @Test fun `opened request reported by both expiry signals shows one expired toast`() = test { sut.setIsAuthenticated(true) From d8cce5ab3f9e85ffd16a416b1991c03959da3e5e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 17:02:11 +0200 Subject: [PATCH 7/7] fix: no expired toast for the request being paid --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 5 +++-- .../repositories/PaykitPaymentRequestRepoTest.kt | 14 ++++++++++---- .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 9 +++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e41f7c1fe2..93fc71b16e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1802,8 +1802,9 @@ class AppViewModel @Inject constructor( private fun toastExpiredPaymentRequests(expired: List) { if (!isPaykitEnabled.value) return - val requestedId = requestedPaymentRequestId.value - val newlyExpired = expired.filter { it.id != requestedId && it.id !in expiredFeedbackRequestIds } + // The opened request and the one being paid get their own feedback. + val handledIds = setOfNotNull(requestedPaymentRequestId.value, activeIncomingPaymentRequest()?.id) + val newlyExpired = expired.filter { it.id !in handledIds && it.id !in expiredFeedbackRequestIds } if (newlyExpired.isEmpty()) return // A refresh that started before the expiry can restore a request, so toast each one once. expiredFeedbackRequestIds += newlyExpired.map { it.id } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 5aafc3c357..88efc8b136 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -1317,20 +1317,26 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `refresh does not report a proposal the snapshot still keeps`() = test { + fun `refresh crossing expiry leaves the report to the expiry timer`() = test { val record = paymentRequestRecord(expiresAt = clock.now().plus(10.seconds).toString()) whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(listOf(record)) sut.refresh().getOrThrow() + val request = sut.pendingRequests.value.single() val reported = mutableListOf>() backgroundScope.launch { sut.expiredProposals.collect { reported += it } } runCurrent() - clockJumpMillis = 5_000 + // The clock passes expiry after the snapshot is parsed and before the pending list is replaced. + // Mockito answers with Result's unboxed value, as the compiled suspend call expects. + whenever(usdtPayments.verifiedReceipts(any())).thenAnswer { + clockJumpMillis = 10_000 + emptyList() + } sut.refresh().getOrThrow() runCurrent() - assertEquals(1, sut.pendingRequests.value.size) - assertTrue(reported.isEmpty()) + assertTrue(sut.pendingRequests.value.isEmpty()) + assertEquals(listOf(listOf(request.id)), reported.map { list -> list.map { it.id } }) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f45005e775..a8b53aab7a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2754,11 +2754,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) val request = paymentRequest() whenever(context.getString(R.string.wallet__payment_request)).thenReturn("Payment Request") + whenever(context.getString(R.string.wallet__payment_request_waiting_for_details)).thenReturn("Waiting") whenever(context.getString(R.string.wallet__payment_request_expired)).thenReturn( "The payment request has expired." ) whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( - Result.failure(PaykitPaymentRequestError.RequestExpired) + Result.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), ) pendingPaykitPaymentRequests.value = listOf(request) enablePaykitUi() @@ -2774,8 +2775,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = emptyList() expiredPaykitProposals.emit(listOf(request)) runCurrent() + advanceTimeBy(2.seconds.inWholeMilliseconds) + runCurrent() - verify(toastManager, times(1)).enqueue(any()) + val toastCaptor = argumentCaptor() + verify(toastManager, atLeastOnce()).enqueue(toastCaptor.capture()) + assertEquals(1, toastCaptor.allValues.count { it.testTag == "PaymentRequestExpiredToast" }) } @Test