diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 430448a937..000f9e57bb 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()) @@ -1387,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) @@ -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,27 @@ class PaykitPaymentRequestRepo @Inject constructor( } } - private suspend fun discardExpiredRequestsLocked() { + private fun replacePendingRequests(incoming: List) { + // 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) + } + + 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) } _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 89c3f602d1..93fc71b16e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -505,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() @@ -1376,6 +1377,9 @@ class AppViewModel @Inject constructor( viewModelScope.launch { paykitPaymentRequestRepo.pendingRequests.drop(1).collect(::onPendingPaykitRequestsChanged) } + viewModelScope.launch { + paykitPaymentRequestRepo.expiredProposals.collect(::toastExpiredPaymentRequests) + } } private suspend fun onPendingPaykitRequestsChanged(requests: List) { @@ -1796,6 +1800,22 @@ class AppViewModel @Inject constructor( } } + private fun toastExpiredPaymentRequests(expired: List) { + if (!isPaykitEnabled.value) return + // 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 } + 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, @@ -1811,6 +1831,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..88efc8b136 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 @@ -242,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 @@ -1294,6 +1298,64 @@ 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 `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() + + // 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() + + 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( + 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 94257e20d6..a8b53aab7a 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()) @@ -2663,6 +2665,124 @@ 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) + + pendingPaykitPaymentRequests.value = emptyList() + expiredPaykitProposals.emit(listOf(request)) + 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 the expiry signal 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(true) + pendingPaykitPaymentRequests.value = emptyList() + runCurrent() + + verify(toastManager, never()).enqueue(any()) + } + + @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() + clearInvocations(toastManager) + + paymentSubmissionActive.value = true + expiredPaykitProposals.emit(listOf(request)) + runCurrent() + + val toastCaptor = argumentCaptor() + verify(toastManager).enqueue(toastCaptor.capture()) + 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) + 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.success(PublicPaykitPaymentResult.WaitingForUpdatedPaymentList), + ) + 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() + advanceTimeBy(2.seconds.inWholeMilliseconds) + runCurrent() + + val toastCaptor = argumentCaptor() + verify(toastManager, atLeastOnce()).enqueue(toastCaptor.capture()) + assertEquals(1, toastCaptor.allValues.count { it.testTag == "PaymentRequestExpiredToast" }) + } + @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. 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 + +