Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -353,6 +356,11 @@ class PaykitPaymentRequestRepo @Inject constructor(
private val targetWriteTickets = mutableMapOf<String, Long>()
private val _pendingRequests = MutableStateFlow<List<PaykitPaymentRequest>>(emptyList())
val pendingRequests: StateFlow<List<PaykitPaymentRequest>> = _pendingRequests.asStateFlow()

private val _expiredProposals = MutableSharedFlow<List<PaykitPaymentRequest>>(extraBufferCapacity = 8)

/** Incoming one-time requests that expired before the user accepted or paid them. */
val expiredProposals: SharedFlow<List<PaykitPaymentRequest>> = _expiredProposals.asSharedFlow()
private val _paymentRequestHistory = MutableStateFlow<List<PaykitPaymentRequest>>(emptyList())
val paymentRequestHistory: StateFlow<List<PaykitPaymentRequest>> = _paymentRequestHistory.asStateFlow()
private val _subscriptions = MutableStateFlow<List<PaykitSubscription>>(emptyList())
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -1734,10 +1742,27 @@ class PaykitPaymentRequestRepo @Inject constructor(
}
}

private suspend fun discardExpiredRequestsLocked() {
private fun replacePendingRequests(incoming: List<PaykitPaymentRequest>) {
// 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)
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ class AppViewModel @Inject constructor(
private var paykitAmountConversion = PaykitAmountConversion()
private val pendingContactPaymentContexts = mutableMapOf<String, ContactPaymentContext>()
private val _requestedPaymentRequestId = MutableStateFlow<PaykitPaymentRequestId?>(null)
private val expiredFeedbackRequestIds = mutableSetOf<PaykitPaymentRequestId>()
val requestedPaymentRequestId = _requestedPaymentRequestId.asStateFlow()
private val paymentRequestPreparation = MutableStateFlow<PaymentRequestPreparation?>(null)
private val dismissedPreparingRequestIds = mutableSetOf<PaykitPaymentRequestId>()
Expand Down Expand Up @@ -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<PaykitPaymentRequest>) {
Expand Down Expand Up @@ -1796,6 +1800,22 @@ class AppViewModel @Inject constructor(
}
}

private fun toastExpiredPaymentRequests(expired: List<PaykitPaymentRequest>) {
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",
Comment thread
ovitrif marked this conversation as resolved.
)
}

private fun finishExpiredPaymentRequestPresentation(request: PaykitPaymentRequest) {
paykitPaymentRequestDiagnostics.logPresentationRejection(
request.counterparty,
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -242,9 +243,12 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) {
private val usdtPayments = mock<PaykitUsdtPaymentRepo>()
private val subscriptionNotificationScheduler = mock<PaykitSubscriptionNotificationScheduler>()
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
Expand Down Expand Up @@ -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<List<PaykitPaymentRequest>>()
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<List<PaykitPaymentRequest>>()
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<Any>()
}
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<List<PaykitPaymentRequest>>()
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(
Expand Down
120 changes: 120 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
private val pubkyContactsLoadCompletionVersion = MutableStateFlow(0L)
private val pubkyContactImportFailure = MutableStateFlow<Throwable?>(null)
private val pendingPaykitPaymentRequests = MutableStateFlow<List<PaykitPaymentRequest>>(emptyList())
private val expiredPaykitProposals = MutableSharedFlow<List<PaykitPaymentRequest>>(extraBufferCapacity = 8)
private val paykitPaymentRequestHistory = MutableStateFlow<List<PaykitPaymentRequest>>(emptyList())
private val paykitSubscriptions = MutableStateFlow<List<PaykitSubscription>>(emptyList())
private val onchainPaymentResolutions = MutableStateFlow<List<PaykitOnchainPaymentProofResolution>>(emptyList())
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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<Toast>()
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<Toast>()
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<Toast>()
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)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1624.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bitkit now shows a toast when a pending payment request expires.
16 changes: 16 additions & 0 deletions journeys/payment-requests/expiry-toast.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<journey name="Payment Request Expiry Toast">
<description>
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.
</description>
<actions>
<action>Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked</action>
<action>Have the issuer send a proposed one-time request for 0.001 btc with a proposal expiry 60 seconds from now</action>
<action>Dismiss the automatic Payment Request confirmation with Back and stay on the home screen without opening the request</action>
<action>Verify the pending Payment Requests bell (testTag "PaymentRequestsBell") is visible</action>
<action>Wait until the proposal expiry passes</action>
<action>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</action>
<action>Verify no second expired toast appears within the next 10 seconds</action>
<action>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</action>
<action>Wait on Payment Request details until the expiry passes and verify at most one expired toast appears</action>
</actions>
</journey>
Loading