diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index a0276e9c35..c08d84c6a5 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -589,8 +589,8 @@ class PaykitPaymentRequestRepo @Inject constructor( completedRefreshGeneration = -1L RefreshCheckpoint(actionVersion, proofVersion) } ?: return@refresh + val priority = if (forceFresh) Priority.Ordered else Priority.Background val snapshot = runSuspendCatching { - val priority = if (forceFresh) Priority.Ordered else Priority.Background fetchRequestSnapshot( expectedIdentity, mode, @@ -605,7 +605,12 @@ class PaykitPaymentRequestRepo @Inject constructor( runSuspendCatching { val records = snapshot.getOrThrow() if (checkpoint != RefreshCheckpoint(actionVersion, proofVersion)) { - synchronizeLocked(generation, expectedIdentity, PaykitPaymentRequestRefreshMode.STORED) + synchronizeLocked( + generation, + expectedIdentity, + PaykitPaymentRequestRefreshMode.STORED, + priority, + ) } else { applyRequestSnapshotLocked(records, generation, expectedIdentity, proofVersion) } @@ -668,6 +673,7 @@ class PaykitPaymentRequestRepo @Inject constructor( } } }.onFailure { + if (it == PaykitPaymentRequestError.OperationInProgress) return@onFailure Logger.warn("Failed to refresh Paykit payment request recipients", it, context = TAG) val savedKeys = savedPublicKeys.mapNotNull(PubkyPublicKeyFormat::normalized).toSet() operationMutex.withLock { @@ -1221,9 +1227,10 @@ class PaykitPaymentRequestRepo @Inject constructor( generation: Long, expectedIdentity: String?, mode: PaykitPaymentRequestRefreshMode = PaykitPaymentRequestRefreshMode.FULL, + priority: Priority = Priority.Ordered, ) { val proofVersion = paymentProofVersion() - val snapshot = fetchRequestSnapshot(expectedIdentity, mode) + val snapshot = fetchRequestSnapshot(expectedIdentity, mode, priority) applyRequestSnapshotLocked(snapshot, generation, expectedIdentity, proofVersion) } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index fb5f028b60..53508a8384 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -294,12 +294,17 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun contactPreparationPriority(priority: Priority): Priority { awaitContactPreparationActive(priority) - val retry = currentCoroutineContext()[PrivateMessageDrainRetry] ?: return priority - return if (retry.interactiveUntil?.let { it > clock.now() } == true) { - Priority.Interactive - } else { - Priority.Background + val retry = currentCoroutineContext()[PrivateMessageDrainRetry] + val admittedPriority = when (retry) { + null -> priority + else -> if (retry.interactiveUntil?.let { it > clock.now() } == true) { + Priority.Interactive + } else { + Priority.Background + } } + paykitSdkService.checkRefreshAdmission(admittedPriority) + return admittedPriority } private fun scheduleContactPreparation( @@ -322,8 +327,9 @@ class PrivatePaykitRepo @Inject constructor( activePreparationKeys = keys val generation = preparationGeneration runSuspendCatching { + paykitSdkService.checkRefreshAdmission(Priority.Background) if (isContactSharingCleanupPending()) return@runSuspendCatching - if (canPublishPrivateEndpoints()) { + if (canPublishPrivateEndpoints(priority = Priority.Background)) { addressReservationRepo.reconcileReservedIndexesWithLdk().getOrThrow() if (generation != preparationGeneration) return@runSuspendCatching publishLocalEndpoints( @@ -340,7 +346,15 @@ class PrivatePaykitRepo @Inject constructor( ) } }.onFailure { - Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) + if (it == PaykitPaymentRequestError.OperationInProgress) { + if (generation == preparationGeneration && !isDeletingProfile) { + pendingPreparationKeys.addAll(keys.intersect(knownSavedContactKeys)) + activePreparationKeys = emptySet() + delay(privateMessageDrainRetryDelays.first()) + } + } else { + Logger.warn("Failed to prepare private Paykit contacts", it, context = TAG) + } } activePreparationKeys = emptySet() } @@ -1013,13 +1027,14 @@ class PrivatePaykitRepo @Inject constructor( ) runSuspendCatching { - awaitContactPreparationActive(priority) + val publicationPriority = contactPreparationPriority(priority) publicationMutex.withLock { - val status = paykitSdkService.identityStatus() + paykitSdkService.checkRefreshAdmission(publicationPriority) + val status = paykitSdkService.identityStatus(publicationPriority) if (!isCurrentPublication(generation, identity, requireImmediatePublication, status)) { return@withLock } - if (!canPublishPrivateEndpoints(status)) { + if (!canPublishPrivateEndpoints(status, publicationPriority)) { if (requireImmediatePublication) throw PrivatePaykitError.PrivateUnavailable return@withLock } @@ -1042,6 +1057,7 @@ class PrivatePaykitRepo @Inject constructor( val report = paykitSdkService.syncPrivatePaymentListsWithReservations( updates = publication.updates, clearUnlistedLinkedPeers = false, + deferDuringPayment = publicationPriority == Priority.Background, ) val deliveryError = applyPrivatePaymentListDeliveryReport(report, reason, publicationWindow) val firstError = publication.firstError ?: deliveryError @@ -1052,7 +1068,9 @@ class PrivatePaykitRepo @Inject constructor( handlePrivatePublicationError(firstError, reason, requireImmediatePublication) } }.onFailure { - schedulePreparedLinkRetries(links.linkRetryKeys, reason, generation, identity) + if (it != PaykitPaymentRequestError.OperationInProgress) { + schedulePreparedLinkRetries(links.linkRetryKeys, reason, generation, identity) + } }.getOrThrow() } } @@ -1169,6 +1187,7 @@ class PrivatePaykitRepo @Inject constructor( if (it != LinkedPeerState.LINKED) linkRetryKeys += publicKey preparedKeys += publicKey }.onFailure { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it Logger.warn("Failed to prepare private Paykit link during '$reason'", it, context = TAG) awaitContactPreparationActive(priority) if (generation != preparationGeneration) return@onFailure @@ -1197,7 +1216,10 @@ class PrivatePaykitRepo @Inject constructor( val state = paykitSdkService.linkedPeers(contactPreparationPriority(priority)) .firstOrNull { it.counterparty == publicKey }?.state state == null || state == LinkedPeerState.NOT_LINKED - }.getOrDefault(false) + }.getOrElse { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it + false + } else -> false } @@ -1242,7 +1264,7 @@ class PrivatePaykitRepo @Inject constructor( priority: Priority = Priority.Ordered, ) { if (isDeletingProfile || generation != preparationGeneration) return - if (isContactSharingCleanupPending() || !hasPrivatePaymentAccessForCurrentProfile()) return + if (isContactSharingCleanupPending() || !hasPrivatePaymentAccessForCurrentProfile(priority)) return if (isDeletingProfile || generation != preparationGeneration) return drainAndSchedulePrivateLinkRetries(reason, publicKeys.distinct(), generation, priority) } @@ -1381,6 +1403,7 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { paykitSdkService.processOutboundPrivateMessages(publicKey, contactPreparationPriority(priority)) }.onFailure { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it Logger.warn("Failed to send private Paykit messages during '$reason'", it, context = TAG) } } @@ -1400,11 +1423,13 @@ class PrivatePaykitRepo @Inject constructor( }.onSuccess { receivedKeys += publicKey }.onFailure { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it Logger.warn("Failed to receive private Paykit messages during '$reason'", it, context = TAG) } } receivedKeys }.onFailure { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it Logger.warn("Failed to process pending private Paykit messages during '$reason'", it, context = TAG) }.getOrDefault(emptySet()) } @@ -1430,6 +1455,7 @@ class PrivatePaykitRepo @Inject constructor( runSuspendCatching { advanceLinkIfIdle(retryKey, priority) }.onFailure { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it currentCoroutineContext()[PrivateMessageDrainRetry]?.stopIfUnavailable(retryKey, it) Logger.warn( "Failed to advance private Paykit link for '${redacted(retryKey)}' during '$reason'", @@ -1499,7 +1525,15 @@ class PrivatePaykitRepo @Inject constructor( retry.refreshReadiness = false Priority.Background } - val keepRetrying = runSuspendCatching { drainPrivateMessageRetry(retry, reason, priority) } + val result = runSuspendCatching { + paykitSdkService.checkRefreshAdmission(priority) + drainPrivateMessageRetry(retry, reason, priority) + } + if (result.exceptionOrNull() == PaykitPaymentRequestError.OperationInProgress) { + retryIndex = 0 + continue + } + val keepRetrying = result .onFailure { Logger.warn("Failed to retry private Paykit messages", it, context = TAG) } .getOrDefault(true) if (!keepRetrying && !retry.prepareEndpoints) return @@ -1647,6 +1681,7 @@ class PrivatePaykitRepo @Inject constructor( val generation = preparationGeneration val snapshot = runSuspendCatching { paykitSdkService.privateMessageSchedulingSnapshot(priority()) } .getOrElse { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it Logger.warn("Failed to inspect private Paykit scheduling state", it, context = TAG) return null } @@ -2128,7 +2163,10 @@ class PrivatePaykitRepo @Inject constructor( return paymentHash in paymentHashes } - private suspend fun canPublishPrivateEndpoints(status: IdentityStatus? = null): Boolean { + private suspend fun canPublishPrivateEndpoints( + status: IdentityStatus? = null, + priority: Priority = Priority.Ordered, + ): Boolean { if (isDeletingProfile) return false val settings = settingsStore.data.first() val locallyEligible = settings.sharesPrivatePaykitEndpoints && @@ -2140,13 +2178,15 @@ class PrivatePaykitRepo @Inject constructor( val hasAccess = if (status?.publicKey != null) { status.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } else { - hasPrivatePaymentAccessForCurrentProfile() + hasPrivatePaymentAccessForCurrentProfile(priority) } return hasAccess && !isDeletingProfile } - private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching { - paykitSdkService.hasPrivatePaymentAccess() + private suspend fun hasPrivatePaymentAccessForCurrentProfile(priority: Priority): Boolean = runSuspendCatching { + paykitSdkService.hasPrivatePaymentAccess(priority) + }.onFailure { + if (it == PaykitPaymentRequestError.OperationInProgress) throw it }.getOrDefault(false) private suspend fun isContactSharingCleanupPending(): Boolean = diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 5e5f68503f..de0f10b33c 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -442,10 +442,14 @@ class PaykitSdkService @Inject constructor( } } - suspend fun hasPrivatePaymentAccess(): Boolean { + suspend fun hasPrivatePaymentAccess(): Boolean = hasPrivatePaymentAccess(Priority.Ordered) + + internal suspend fun hasPrivatePaymentAccess(priority: Priority): Boolean { isSetup.await() - return operationLock.withLock { + return operationLock.withLock(priority) { + checkRefreshAdmission(priority) withPaykitKey { + checkRefreshAdmission(priority) completeSdkCall { it.identityStatus() }?.capability == PubkyIdentityCapability.PRIVATE_LINK_CAPABLE } } @@ -857,6 +861,16 @@ class PaykitSdkService @Inject constructor( suspend fun syncPrivatePaymentListsWithReservations( updates: List, clearUnlistedLinkedPeers: Boolean, + ): PrivatePaymentListDeliveryReport = syncPrivatePaymentListsWithReservations( + updates, + clearUnlistedLinkedPeers, + deferDuringPayment = false, + ) + + internal suspend fun syncPrivatePaymentListsWithReservations( + updates: List, + clearUnlistedLinkedPeers: Boolean, + deferDuringPayment: Boolean, ): PrivatePaymentListDeliveryReport { isSetup.await() val priority = if (clearUnlistedLinkedPeers || updates.any { it.reservations.isEmpty() }) { @@ -865,7 +879,9 @@ class PaykitSdkService @Inject constructor( PaykitSdkOperationLock.Priority.Background } return operationLock.withLock(priority) { - withStateRevisionTracking { handle -> + val admissionPriority = if (deferDuringPayment) priority else Priority.Ordered + checkRefreshAdmission(admissionPriority) + withStateRevisionTracking(beforeOperation = { checkRefreshAdmission(admissionPriority) }) { handle -> completeSdkCall { handle.syncPrivatePaymentListsWithReservationsAndProcessOutbound( updates = updates, @@ -1062,13 +1078,16 @@ class PaykitSdkService @Inject constructor( ): List { isSetup.await() return measuredOperation("allPaymentRequests", priority) { + checkRefreshAdmission(priority) withPaykitKey { handle -> + checkRefreshAdmission(priority) if (expectedIdentity != null) { val identity = completeSdkCall { handle.identityStatus() }?.publicKey check(PubkyPublicKeyFormat.matches(identity, expectedIdentity)) { "Payment Request identity changed" } } + checkRefreshAdmission(priority) completeSdkCall { handle.listPaymentRequests( PaymentRequestFilter( @@ -1089,7 +1108,13 @@ class PaykitSdkService @Inject constructor( internal suspend fun identityStatus(priority: Priority): IdentityStatus? { isSetup.await() return measuredOperation("identityStatus", priority) { - withPaykitKey { handle -> retryContendedRead { handle.identityStatus() } } + checkRefreshAdmission(priority) + withPaykitKey { handle -> + retryContendedRead { + checkRefreshAdmission(priority) + handle.identityStatus() + } + } } } @@ -1230,7 +1255,13 @@ class PaykitSdkService @Inject constructor( internal suspend fun linkedPeers(priority: Priority): List { isSetup.await() return operationLock.withLock(priority) { - withPaykitKey { handle -> retryContendedRead { handle.linkedPeers() } } + checkRefreshAdmission(priority) + withPaykitKey { handle -> + retryContendedRead { + checkRefreshAdmission(priority) + handle.linkedPeers() + } + } } } @@ -1239,7 +1270,13 @@ class PaykitSdkService @Inject constructor( ): PrivateMessageSchedulingSnapshot { isSetup.await() return operationLock.withLock(priority) { - withPaykitKey { handle -> retryContendedRead { handle.privateMessageSchedulingSnapshot() } } + checkRefreshAdmission(priority) + withPaykitKey { handle -> + retryContendedRead { + checkRefreshAdmission(priority) + handle.privateMessageSchedulingSnapshot() + } + } } } @@ -1553,7 +1590,7 @@ class PaykitSdkService @Inject constructor( cachedBackupState = null } - private fun checkRefreshAdmission(priority: Priority) { + internal fun checkRefreshAdmission(priority: Priority) { val reviewing = synchronized(paymentReviews) { paymentReviews.isNotEmpty() } if (priority == Priority.Background && (isPaymentSubmissionActive.value || reviewing)) { throw PaykitPaymentRequestError.OperationInProgress diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt index 40c3dbb0cc..e337594f4f 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoTest.kt @@ -511,6 +511,68 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(sut.pendingRequests.value.isEmpty()) } + @Test + fun `checkpoint reload preserves refresh admission and filters local proofs on retry`() = test { + for (forceFresh in listOf(false, true)) { + val record = paymentRequestRecord() + val other = paymentRequestRecord(id = "another-request") + val records = listOf(record, other) + val requestId = PaykitPaymentRequestId(PAYMENT_REQUEST_ID, COUNTERPARTY) + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(emptySet()) + whenever(paykitSdkService.allPaymentRequests(anyOrNull())).thenReturn(records) + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + val warmRequests = sut.pendingRequests.value + val warmHistory = sut.paymentRequestHistory.value + val reading = CompletableDeferred() + val resume = CompletableDeferred() + var reads = 0 + doSuspendableAnswer { + if (reads++ == 0) { + reading.complete(Unit) + resume.await() + } + records + }.whenever(paykitSdkService).allPaymentRequests(anyOrNull()) + doSuspendableAnswer { + if (paymentSubmissionActive.value && it.getArgument(1) == Priority.Background) { + throw PaykitPaymentRequestError.OperationInProgress + } + records + }.whenever(paykitSdkService).allPaymentRequests(anyOrNull(), any()) + clearInvocations(paykitSdkService) + val refresh = async { + if (forceFresh) { + sut.refreshAfterStateChange(PaykitPaymentRequestRefreshMode.STORED) + } else { + sut.refresh(PaykitPaymentRequestRefreshMode.STORED) + } + } + reading.await() + whenever(paymentProofStore.inFlightRequestIds(LOCAL_IDENTITY)).thenReturn(setOf(requestId)) + proofStateVersion.value += 1 + paymentSubmissionActive.value = true + resume.complete(Unit) + + val result = refresh.await() + if (forceFresh) { + result.getOrThrow() + assertEquals(listOf("another-request"), sut.pendingRequests.value.map { it.paymentRequestId }) + } else { + assertSame(PaykitPaymentRequestError.OperationInProgress, result.exceptionOrNull()) + assertEquals(warmRequests, sut.pendingRequests.value) + assertEquals(warmHistory, sut.paymentRequestHistory.value) + verify(paykitSdkService, never()).linkedPeers(Priority.Background) + } + val priority = if (forceFresh) Priority.Ordered else Priority.Background + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, priority) + + paymentSubmissionActive.value = false + sut.refresh(PaykitPaymentRequestRefreshMode.STORED).getOrThrow() + assertEquals(listOf("another-request"), sut.pendingRequests.value.map { it.paymentRequestId }) + assertTrue(sut.paymentRequestHistory.value.any { it.id == requestId }) + } + } + @Test fun `passive refresh message priority does not change action or forced refresh drains`() = test { sut.refresh(PaykitPaymentRequestRefreshMode.STORED, Priority.Background).getOrThrow() @@ -589,7 +651,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService, times(if (failure == null) 2 else 1)).processPendingPrivateMessages() verify(paykitSdkService).receivePrivateMessagesFromLinkedPeers() verify(paykitSdkService).observeState(LOCAL_IDENTITY, Priority.Background) - verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) } } @@ -820,7 +882,7 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } verify(paykitSdkService).observeState(LOCAL_IDENTITY, Priority.Background) - verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Ordered) + verify(paykitSdkService).allPaymentRequests(LOCAL_IDENTITY, Priority.Background) verify(paykitSdkService, never()).processPendingPrivateMessages() verify(paykitSdkService, never()).receivePrivateMessagesFromLinkedPeers() } @@ -2450,6 +2512,31 @@ class PaykitPaymentRequestRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(paykitSdkService).linkedPeers(Priority.Background) } + @Test + fun `deferred bulk recipient discovery preserves targets and leaves interactive refresh available`() = test { + val status = IdentityStatus(LOCAL_IDENTITY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + whenever(paykitSdkService.identityStatus()).thenReturn(status) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.LINKED))) + whenever(paykitSdkService.canReceivePaymentRequests(eq(COUNTERPARTY), any())).thenReturn(true) + sut.refreshEligibleTargets(listOf(COUNTERPARTY)).getOrThrow() + val targets = sut.eligibleTargets.value + var deferred = true + doSuspendableAnswer { + if (deferred) throw PaykitPaymentRequestError.OperationInProgress + status + }.whenever(paykitSdkService).identityStatus(Priority.Background) + + assertEquals( + PaykitPaymentRequestError.OperationInProgress, + sut.refreshEligibleTargets(emptyList()).exceptionOrNull(), + ) + assertEquals(targets, sut.eligibleTargets.value) + assertTrue(sut.refreshEligibleTarget(COUNTERPARTY).getOrThrow().isComplete) + deferred = false + sut.refreshEligibleTargets(emptyList()).getOrThrow() + assertTrue(sut.eligibleTargets.value.isEmpty()) + } + @Test fun `single recipient refresh removes a contact that is no longer linked`() = test { whenever( diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 5e0aa48fea..3ccefb6c3a 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -46,6 +46,7 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -54,6 +55,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import to.bitkit.App import to.bitkit.CurrentActivity @@ -156,6 +158,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { .thenReturn(Result.success(PRIVATE_ADDRESS)) whenever { paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any()) } .thenReturn(privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY))) + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), any(), any())).doSuspendableAnswer { + paykitSdkService.syncPrivatePaymentListsWithReservations(it.getArgument(0), it.getArgument(1)) + } whenever { paykitSdkService.linkedPeers() }.thenReturn(emptyList()) whenever { paykitSdkService.ensureLinkWithPeer(any(), any(), eq(Priority.Ordered)) }.thenAnswer { LinkedPeerHandshakeReport(it.getArgument(0), LinkedPeerState.LINKED, 1uL, null) @@ -339,7 +344,9 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() runCurrent() - verifyNoInteractions(paykitSdkService, addressReservationRepo) + verify(paykitSdkService).checkRefreshAdmission(Priority.Background) + verifyNoMoreInteractions(paykitSdkService) + verifyNoInteractions(addressReservationRepo) val updateStarted = CompletableDeferred() val resumeUpdate = CompletableDeferred() @@ -1091,9 +1098,15 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { advanceTimeBy(1_000) runCurrent() clearInvocations(paykitSdkService) + doSuspendableAnswer { + throw PaykitPaymentRequestError.OperationInProgress + }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(any()) advanceTimeBy(3_000) runCurrent() verify(paykitSdkService, atLeast(1)).privateMessageSchedulingSnapshot(Priority.Background) + verify(paykitSdkService, never()).ensureLinkWithPeer(any(), any(), any()) + verify(paykitSdkService, never()).receivePrivateMessages(any(), any()) + verify(paykitSdkService, never()).processOutboundPrivateMessages(any(), any()) doSuspendableAnswer { PrivateMessageSchedulingSnapshot(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED)), emptyList()) }.whenever(paykitSdkService).privateMessageSchedulingSnapshot(any()) @@ -1572,6 +1585,118 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { sut.closeAndClear() } + @Test + fun `queued automatic publication reads retain contacts when payment starts`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + for (read in listOf("capability", "identity")) { + sut = createSut() + val queued = CompletableDeferred() + val release = CompletableDeferred() + var paymentActive = false + suspend fun awaitAdmission(priority: Priority) { + queued.complete(Unit) + release.await() + if (paymentActive && priority == Priority.Background) { + throw PaykitPaymentRequestError.OperationInProgress + } + } + doSuspendableAnswer { + if (read == "capability") awaitAdmission(it.getArgument(0)) + paykitSdkService.hasPrivatePaymentAccess() + }.whenever(paykitSdkService).hasPrivatePaymentAccess(any()) + doSuspendableAnswer { + if (read == "identity") awaitAdmission(it.getArgument(0)) + paykitSdkService.identityStatus() + }.whenever(paykitSdkService).identityStatus(any()) + clearInvocations(paykitSdkService, addressReservationRepo) + + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + assertTrue(queued.isCompleted, read) + paymentActive = true + release.complete(Unit) + runCurrent() + + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any(), any()) + verify(addressReservationRepo, never()).currentOrRotatedAddress(any()) + assertEquals(0L, sut.backupStateVersion.value) + assertFalse(cacheData.value.contacts[CONTACT_KEY]?.hasPublishedPrivatePaymentList == true) + + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + verify(paykitSdkService).identityStatus(Priority.Ordered) + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false), eq(false)) + + paymentActive = false + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false), eq(true)) + assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) + sut.closeAndClear() + } + } + + @Test + fun `deferred automatic publication retains contacts and releases explicit publication`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitUsdtEnabled = false, + ) + whenever(paykitSdkService.linkedPeers()).thenReturn(listOf(linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED))) + var automaticCalls = 0 + whenever(paykitSdkService.syncPrivatePaymentListsWithReservations(any(), eq(false), eq(true))) + .doSuspendableAnswer { + automaticCalls++ + if (automaticCalls == 1) throw PaykitPaymentRequestError.OperationInProgress + privateListDeliveryReport(queuedCounterparties = listOf(CONTACT_KEY)) + } + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + assertEquals(1, automaticCalls) + assertEquals(0L, sut.backupStateVersion.value) + assertFalse(cacheData.value.contacts[CONTACT_KEY]?.hasPublishedPrivatePaymentList == true) + + sut.refreshSavedContactEndpoints(CONTACT_KEY, listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + verify(paykitSdkService, atLeast(1)).linkedPeers(Priority.Interactive) + verify(paykitSdkService).syncPrivatePaymentListsWithReservations(any(), eq(false), eq(false)) + sut.prepareSavedContacts(listOf(CONTACT_KEY), requireImmediatePublication = true).getOrThrow() + verify(paykitSdkService, times(2)).syncPrivatePaymentListsWithReservations(any(), eq(false), eq(false)) + advanceTimeBy(1_000) + runCurrent() + assertEquals(2, automaticCalls) + assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList) + sut.closeAndClear() + } + + @Test + fun `deferred preparation cannot resume after reset or profile deletion`() = test { + for (close in listOf(false, true)) { + sut = createSut() + sut.setContactPreparationActive(true) + var blocked = true + doAnswer { + if (blocked) throw PaykitPaymentRequestError.OperationInProgress + Unit + }.whenever(paykitSdkService).checkRefreshAdmission(Priority.Background) + sut.scheduleSavedContactPreparation(listOf(CONTACT_KEY)).getOrThrow() + runCurrent() + clearInvocations(paykitSdkService) + if (close) sut.closeAndClear().getOrThrow() else sut.beginProfileDeletion() + blocked = false + advanceTimeBy(1_000) + runCurrent() + verify(paykitSdkService, never()).linkedPeers(any()) + verify(paykitSdkService, never()).syncPrivatePaymentListsWithReservations(any(), any(), any()) + sut.closeAndClear() + } + } + @Test fun `scheduled preparation waits for foreground and retains selected contacts`() = test { sut.setContactPreparationActive(false) @@ -4071,6 +4196,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { .doSuspendableAnswer { paykitSdkService.ensureLinkWithPeer(it.getArgument(0)) } } whenever { paykitSdkService.identityStatus(any()) }.doSuspendableAnswer { paykitSdkService.identityStatus() } + whenever { paykitSdkService.hasPrivatePaymentAccess(any()) } + .doSuspendableAnswer { paykitSdkService.hasPrivatePaymentAccess() } } private suspend fun preparePrivateLightningContact(keys: List = listOf(CONTACT_KEY)) { diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index f4056f8672..11cc4b4326 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -122,9 +122,14 @@ class PaykitSdkServiceTest { { receivePrivateMessagesFromLinkedPeers(it) }, { processPendingPrivateMessages(it) }, { observeState(RING_PUBKY, it) }, + { identityStatus(it) }, + { linkedPeers(it) }, + { privateMessageSchedulingSnapshot(it) }, + { hasPrivatePaymentAccess(it) }, + { allPaymentRequests(null, it) }, ) for ((index, operation) in operations.withIndex()) { - for (queued in if (index == 2) listOf(true) else listOf(true, false)) { + for (queued in if (index >= 2) listOf(true) else listOf(true, false)) { val sdk = mock() val release = CompletableDeferred() var reached = false @@ -161,6 +166,22 @@ class PaykitSdkServiceTest { emptyList(), ) } + whenever(sdk.identityStatus()).thenAnswer { + nativeCalls++ + null + } + whenever(sdk.linkedPeers()).thenAnswer { + nativeCalls++ + emptyList() + } + whenever(sdk.privateMessageSchedulingSnapshot()).thenAnswer { + nativeCalls++ + PrivateMessageSchedulingSnapshot(emptyList(), emptyList()) + } + whenever(sdk.listPaymentRequests(any())).thenAnswer { + nativeCalls++ + emptyList() + } val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } val holder = if (queued) async { service.contactRecords() } else null runCurrent() @@ -179,7 +200,61 @@ class PaykitSdkServiceTest { service.setPaymentSubmissionActive(false) service.operation(Priority.Background) assertEquals(2, nativeCalls) + service.setPaymentSubmissionActive(true) + service.operation(Priority.Interactive) + assertEquals(3, nativeCalls) + } + } + } + + @Test + fun `automatic publication yields before mutation while explicit publication and cleanup proceed`() = runTest { + for (queued in listOf(true, false)) { + val sdk = mock() + val reached = CompletableDeferred() + val release = CompletableDeferred() + whenever(sdk.contactRecords()).doSuspendableAnswer { + reached.complete(Unit) + release.await() + emptyList() + } + whenever(sdk.backupStateRevision()).doSuspendableAnswer { + if (!queued) { + reached.complete(Unit) + release.await() + } + "revision" + } + val report = PrivatePaymentListDeliveryReport(emptyList(), emptyList(), emptyList(), emptyList()) + whenever(sdk.syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any())).thenReturn(report) + val updates = listOf(PrivatePaymentListReservationUpdateInput(RING_PUBKY, listOf(mock()))) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val holder = if (queued) async { service.contactRecords() } else null + val publication = async { + runSuspendCatching { service.syncPrivatePaymentListsWithReservations(updates, false, true) } + } + reached.await() + if (queued) { + val cancelled = async { + service.syncPrivatePaymentListsWithReservations(updates, false, true) + } + runCurrent() + cancelled.cancel() + assertFailsWith { cancelled.await() } } + service.setPaymentSubmissionActive(true) + release.complete(Unit) + holder?.await() + + assertSame(PaykitPaymentRequestError.OperationInProgress, publication.await().exceptionOrNull()) + verify(sdk, never()).syncPrivatePaymentListsWithReservationsAndProcessOutbound(any(), any()) + assertEquals(0L, service.backupStateVersion.value) + assertSame(report, service.syncPrivatePaymentListsWithReservations(updates, false)) + val cleanup = listOf(PrivatePaymentListReservationUpdateInput(RING_PUBKY, emptyList())) + assertSame(report, service.syncPrivatePaymentListsWithReservations(cleanup, false, true)) + service.setPaymentSubmissionActive(false) + assertSame(report, service.syncPrivatePaymentListsWithReservations(updates, false, true)) + verify(sdk, times(2)).syncPrivatePaymentListsWithReservationsAndProcessOutbound(updates, false) } } @@ -202,6 +277,11 @@ class PaykitSdkServiceTest { { receivePrivateMessagesFromLinkedPeers(Priority.Background) }, { processPendingPrivateMessages(Priority.Background) }, { observeState(RING_PUBKY) }, + { identityStatus(Priority.Background) }, + { linkedPeers(Priority.Background) }, + { privateMessageSchedulingSnapshot(Priority.Background) }, + { hasPrivatePaymentAccess(Priority.Background) }, + { allPaymentRequests(null, Priority.Background) }, ) for (operation in operations) { assertSame( @@ -1868,6 +1948,35 @@ class PaykitSdkServiceTest { assertEquals(listOf(bitkit), service.paymentRequests()) } + @Test + fun `passive request reload defers after identity check without listing and releases its lock`() = runTest { + val sdk = mock() + val checkingIdentity = CompletableDeferred() + val releaseIdentity = CompletableDeferred() + whenever(sdk.identityStatus()).doSuspendableAnswer { + checkingIdentity.complete(Unit) + releaseIdentity.await() + IdentityStatus(RING_PUBKY, PubkyIdentityCapability.PRIVATE_LINK_CAPABLE) + } + val records = listOf(mock()) + whenever(sdk.listPaymentRequests(any())).thenReturn(records) + val service = PaykitSdkService(mock(), mock(), mock(), settingsStore = mock()) { sdk } + val refresh = async { + runSuspendCatching { service.allPaymentRequests(RING_PUBKY, Priority.Background) } + } + checkingIdentity.await() + service.setPaymentSubmissionActive(true) + releaseIdentity.complete(Unit) + + assertSame(PaykitPaymentRequestError.OperationInProgress, refresh.await().exceptionOrNull()) + verify(sdk, never()).listPaymentRequests(any()) + assertEquals(0L, service.backupStateVersion.value) + assertEquals(records, service.allPaymentRequests(RING_PUBKY)) + service.setPaymentSubmissionActive(false) + assertEquals(records, service.allPaymentRequests(RING_PUBKY, Priority.Background)) + verify(sdk, times(2)).listPaymentRequests(any()) + } + @Test fun `all payment requests reject a different active identity before listing`() = runTest { val sdk = mock() diff --git a/changelog.d/next/1626.fixed.md b/changelog.d/next/1626.fixed.md new file mode 100644 index 0000000000..ad557222de --- /dev/null +++ b/changelog.d/next/1626.fixed.md @@ -0,0 +1 @@ +Defer automatic Paykit contact updates during payment review and sending, and retry them afterward.