diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 08ee7f113..416de57d0 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -13,8 +13,11 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job 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 @@ -85,6 +88,7 @@ class PrivatePaykitRepo @Inject constructor( 45.seconds, 90.seconds, ) + private val initialLinkBurstRetryDelays = List(14) { 2.seconds } private val privatePaymentResolutionRetryDelays = privateMessageDrainRetryDelays.take(3) fun isDuplicatePaymentError(error: Throwable): Boolean = @@ -100,6 +104,12 @@ class PrivatePaykitRepo @Inject constructor( private val pendingMessageDrainRetryKeys = mutableSetOf() private var pendingMessageDrainRetryJob: Job? = null private var pendingMessageDrainRetryGeneration = 0 + private val _initialLinkBurstStarted = MutableSharedFlow(extraBufferCapacity = 1) + val initialLinkBurstStarted: SharedFlow = _initialLinkBurstStarted.asSharedFlow() + private val initialLinkBurstLock = Any() + private val initialLinkBurstPublicKeys = mutableSetOf() + private var initialLinkBurstJob: Job? = null + private var initialLinkBurstGeneration = 0 private data class PrivatePublicationPreparation( val updates: List, @@ -219,6 +229,35 @@ class PrivatePaykitRepo @Inject constructor( } } + fun startInitialLinkBurst(publicKeys: Collection, reason: String) { + val publicKeys = normalizedPublicKeyBatch(publicKeys).normalizedKeys + if (publicKeys.isEmpty()) return + + synchronized(initialLinkBurstLock) { + initialLinkBurstPublicKeys += publicKeys + initialLinkBurstGeneration += 1 + val generation = initialLinkBurstGeneration + initialLinkBurstJob?.cancel() + _initialLinkBurstStarted.tryEmit(Unit) + + initialLinkBurstJob = retryScope.launch { + (listOf(kotlin.time.Duration.ZERO) + initialLinkBurstRetryDelays).forEach { retryDelay -> + delay(retryDelay) + val keys = synchronized(initialLinkBurstLock) { + if (generation != initialLinkBurstGeneration) return@launch + initialLinkBurstPublicKeys.toList() + } + refreshSavedContactEndpointsDuringInitialLinkBurst(keys, reason) + } + synchronized(initialLinkBurstLock) { + if (generation != initialLinkBurstGeneration) return@launch + initialLinkBurstJob = null + initialLinkBurstPublicKeys.clear() + } + } + } + } + suspend fun retryPendingEndpointRemoval( savedPublicKeys: Collection, ): Result = withContext(serializedDispatcher) { @@ -303,6 +342,7 @@ class PrivatePaykitRepo @Inject constructor( suspend fun closeAndClear(): Result = withContext(serializedDispatcher) { runSuspendCatching { publicationMutex.withLock { + clearInitialLinkBurst() clearPendingMessageDrainRetries() knownSavedContactKeys.clear() state = PrivatePaykitState() @@ -1349,11 +1389,52 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun receiverPathsForSavedContact(publicKey: String): List { - val paths = paykitSdkService.contactRecord(publicKey) - ?.receiverPaths - ?.filter { it in PaykitReceiverPaths.supported } - .orEmpty() - return paths.ifEmpty { listOf(PaykitReceiverPaths.WALLET) } + val record = paykitSdkService.contactRecord(publicKey) + val savedPaths = supportedReceiverPaths(record?.receiverPaths.orEmpty()) + + return runSuspendCatching { + val discoveredPaths = pubkyService.discoverRelevantReceiverPaths(publicKey) + val mergedPaths = supportedReceiverPaths(savedPaths + discoveredPaths) + if (mergedPaths == savedPaths) return@runSuspendCatching savedPaths + + val updatedRecord = pubkyService.saveContact(publicKey, record?.label, mergedPaths) + _initialLinkBurstStarted.tryEmit(Unit) + Logger.info("Discovered new Paykit receiver paths for '${redacted(publicKey)}'", context = TAG) + supportedReceiverPaths(updatedRecord.receiverPaths) + }.getOrElse { + if (it is CancellationException) throw it + Logger.warn( + "Failed to refresh Paykit receiver paths for '${redacted(publicKey)}'; using saved paths", + it, + context = TAG, + ) + savedPaths + } + } + + private fun supportedReceiverPaths(receiverPaths: Collection): List = + PaykitReceiverPaths.supported.filter { it in receiverPaths } + .ifEmpty { listOf(PaykitReceiverPaths.WALLET) } + + private suspend fun refreshSavedContactEndpointsDuringInitialLinkBurst( + publicKeys: Collection, + reason: String, + ) = withContext(serializedDispatcher) { + if (!canPublishPrivateEndpoints()) { + prepareRelevantPrivateLinksIfAvailable(publicKeys, "$reason initial link burst") + return@withContext + } + publishLocalEndpoints(publicKeys.toList(), reason = "$reason initial link burst") + .onFailure { Logger.warn("Failed initial private Paykit sync for '$reason'", it, context = TAG) } + } + + private fun clearInitialLinkBurst() { + synchronized(initialLinkBurstLock) { + initialLinkBurstJob?.cancel() + initialLinkBurstJob = null + initialLinkBurstPublicKeys.clear() + initialLinkBurstGeneration += 1 + } } private fun receiverPathsForPrivateEndpointCleanup( diff --git a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt index 010c16ca7..be69e7623 100644 --- a/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt @@ -24,6 +24,7 @@ class RefreshContactPaykitReceiversUseCase @Inject constructor( pubkyRepo.refreshContactReceiverPaths(publicKey).getOrThrow() val savedPublicKeys = (pubkyRepo.contacts.value.map { it.publicKey } + publicKey).distinct() privatePaykitRepo.refreshSavedContactEndpoints(publicKey, savedPublicKeys).getOrThrow() + privatePaykitRepo.startInitialLinkBurst(savedPublicKeys, "contact receiver refresh") }.onFailure { Logger.warn( "Failed to refresh Paykit receivers for '${PubkyPublicKeyFormat.redacted(publicKey)}'", diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6ce64be2a..1bb44d9f2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -296,6 +296,7 @@ class AppViewModel @Inject constructor( private var isPresentingPaymentRequest = false private var isSubmittingPaymentRequest = false private var paykitPaymentRequestPollingJob: Job? = null + private var initialPaykitPaymentRequestPollingJob: Job? = null private val paymentRequestPresentationRetryAttempts = mutableMapOf() private val paymentRequestPresentationRetryJobs = mutableMapOf() private val timedSheetManager = timedSheetManagerProvider(viewModelScope).apply { @@ -420,6 +421,7 @@ class AppViewModel @Inject constructor( observePublicPaykitInvoiceExpiry() observePrivatePaykitContacts() observePaykitPaymentRequestConnectivity() + observeInitialPaykitLinkBursts() observeIncomingPaykitPaymentRequests() observeSendEvents() viewModelScope.launch { @@ -583,6 +585,7 @@ class AppViewModel @Inject constructor( .onFailure { Logger.warn("Failed to prune private Paykit contact state", it, context = TAG) } + privatePaykitRepo.startInitialLinkBurst(state.contactKeys, "contact sync") refreshIncomingPaykitPaymentRequests() lastPrivatePaykitContactKeys = state.contactKeys } @@ -604,6 +607,7 @@ class AppViewModel @Inject constructor( Logger.warn("Failed to reconcile private Paykit receive indexes for '$reason'", it, context = TAG) } privatePaykitRepo.refreshKnownSavedContactEndpoints(reason, forceRefreshLightning = forceRefreshLightning) + privatePaykitRepo.startInitialLinkBurst(contactKeys, reason) refreshIncomingPaykitPaymentRequests() } @@ -612,7 +616,13 @@ class AppViewModel @Inject constructor( isOnline .drop(1) .filter { it == ConnectivityState.CONNECTED } - .collect { refreshIncomingPaykitPaymentRequests() } + .collect { refreshPrivatePaykitEndpointsIfEnabled("network restored") } + } + } + + private fun observeInitialPaykitLinkBursts() { + viewModelScope.launch { + privatePaykitRepo.initialLinkBurstStarted.collect { startInitialPaykitPaymentRequestPolling() } } } @@ -635,6 +645,7 @@ class AppViewModel @Inject constructor( var refreshIntervalIndex = 0 while (true) { delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS[refreshIntervalIndex]) + privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") refreshIntervalIndex = if (refreshIncomingPaykitPaymentRequests()) { 0 } else { @@ -642,16 +653,31 @@ class AppViewModel @Inject constructor( } } } + startInitialPaykitPaymentRequestPolling() } fun stopPaykitPaymentRequestPolling() { paykitPaymentRequestPollingJob?.cancel() paykitPaymentRequestPollingJob = null + initialPaykitPaymentRequestPollingJob?.cancel() + initialPaykitPaymentRequestPollingJob = null paymentRequestPresentationRetryJobs.values.forEach { it.cancel() } paymentRequestPresentationRetryJobs.clear() paymentRequestPresentationRetryAttempts.clear() } + private fun startInitialPaykitPaymentRequestPolling() { + if (paykitPaymentRequestPollingJob?.isActive != true) return + initialPaykitPaymentRequestPollingJob?.cancel() + initialPaykitPaymentRequestPollingJob = viewModelScope.launch { + refreshIncomingPaykitPaymentRequests() + INITIAL_PAYKIT_SYNC_RETRY_DELAYS.forEach { + delay(it) + refreshIncomingPaykitPaymentRequests() + } + } + } + private fun observeIncomingPaykitPaymentRequests() { viewModelScope.launch { currentSheet.collect { @@ -695,8 +721,12 @@ class AppViewModel @Inject constructor( val result = privatePaykitRepo.beginPaymentRequest(request).getOrNull() if (currentSheet.value != null || hasActiveContactPaymentContext()) return true val isPending = paykitPaymentRequestRepo.isPending(request) - if (result !is PublicPaykitPaymentResult.Opened || !isPending) { - if (isPending) deferPaymentRequestPresentation(request) + if (!isPending) { + presentedPaymentRequestIds += request.id + return false + } + if (result !is PublicPaykitPaymentResult.Opened) { + deferPaymentRequestPresentation(request) return false } @@ -3717,6 +3747,7 @@ class AppViewModel @Inject constructor( private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L private const val PAYKIT_CHANNEL_USABILITY_REFRESH_DELAY_MS = 5_000L private val PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS = listOf(30.seconds, 60.seconds, 120.seconds) + private val INITIAL_PAYKIT_SYNC_RETRY_DELAYS = List(14) { 2.seconds } private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS = listOf( 30.seconds, 60.seconds, diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index cbc7d5482..d94cce541 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -50,6 +50,35 @@ class PubkyAuthRequestTest { assertNull(request.bitkitClaim) } + @Test + fun `parse deduplicates service name across public and private capabilities`() { + val capabilities = "/pub/locks.app/:rw,/priv/locks.app/:rw" + + val request = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ).getOrThrow() + + assertEquals(listOf("/pub/locks.app/", "/priv/locks.app/"), request.permissions.map { it.path }) + assertEquals(listOf("locks.app"), request.serviceNames) + } + + @Test + fun `parse deduplicates service names across multiple paths in first-seen order`() { + val capabilities = + "/pub/locks.app/posts/:r,/pub/example.app/:r,/priv/locks.app/settings/:w,/priv/example.app/cache/:r" + + val request = PubkyAuthRequest.parse( + rawUrl = authUrl(capabilities), + relay = "https://httprelay.pubky.app/inbox/", + capabilities = capabilities, + ).getOrThrow() + + assertEquals(4, request.permissions.size) + assertEquals(listOf("locks.app", "example.app"), request.serviceNames) + } + @Test fun `parse rejects watch-only capability without Bitkit claim`() { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 0cbc0df75..74c213d10 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -122,6 +122,8 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(lightningRepo.lightningState).thenReturn(lightningState) whenever(clock.now()).thenReturn(Instant.fromEpochSeconds(NOW_SECONDS)) whenever(pubkyService.currentPublicKey()).thenReturn(OWN_KEY) + whenever { pubkyService.discoverRelevantReceiverPaths(any()) } + .thenReturn(listOf(WALLET_RECEIVER_PATH)) whenever(paykitSdkService.hasPrivatePaymentAccess()).thenReturn(true) whenever(walletRepo.walletExists()).thenReturn(true) whenever { walletRepo.refreshReusableReceiveAddressIfReserved() }.thenReturn(Result.success(Unit)) @@ -299,6 +301,43 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verify(addressReservationRepo, never()).currentOrRotatedAddress(any(), any()) } + @Test + fun `initial link burst discovers a server receiver published after the contact was saved`() = test { + settingsData.value = SettingsData(sharesPrivatePaykitEndpoints = false) + whenever { paykitSdkService.contactRecord(CONTACT_KEY) } + .thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH))) + whenever { pubkyService.discoverRelevantReceiverPaths(CONTACT_KEY) } + .thenReturn(listOf(WALLET_RECEIVER_PATH)) + .thenReturn(listOf(WALLET_RECEIVER_PATH)) + .thenReturn(listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH)) + whenever { + pubkyService.saveContact( + CONTACT_KEY, + null, + listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + ) + }.thenReturn(contactRecord(CONTACT_KEY, listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH))) + + assertTrue(sut.prepareSavedContacts(listOf(CONTACT_KEY)).isSuccess) + clearInvocations(paykitSdkService, pubkyService) + + sut.startInitialLinkBurst(listOf(CONTACT_KEY), "test") + runCurrent() + advanceTimeBy(2_000) + runCurrent() + + verifyBlocking(pubkyService) { + saveContact( + CONTACT_KEY, + null, + listOf(WALLET_RECEIVER_PATH, SERVER_RECEIVER_PATH), + ) + } + verifyBlocking(paykitSdkService) { ensureLinkWithPeer(CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + sut.closeAndClear() + } + @Test fun `prepareSavedContacts clears receiver paths that are no longer eligible`() = test { settingsData.value = SettingsData( diff --git a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt index ee5801e7b..052a91a00 100644 --- a/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCaseTest.kt @@ -56,6 +56,7 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { inOrder(pubkyRepo, privatePaykitRepo).apply { verify(pubkyRepo).refreshContactReceiverPaths(contactKeys.last()) verify(privatePaykitRepo).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) + verify(privatePaykitRepo).startInitialLinkBurst(contactKeys, "contact receiver refresh") } } @@ -68,5 +69,6 @@ class RefreshContactPaykitReceiversUseCaseTest : BaseUnitTest() { assertEquals(error, result.exceptionOrNull()) verify(privatePaykitRepo, never()).refreshSavedContactEndpoints(contactKeys.last(), contactKeys) + verify(privatePaykitRepo, never()).startInitialLinkBurst(contactKeys, "contact receiver refresh") } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 181f88299..de63ff102 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -32,6 +32,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.TransactionDetails import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.atLeast import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doSuspendableAnswer @@ -186,6 +187,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @After fun tearDown() { + sut.stopPaykitPaymentRequestPolling() App.currentActivity = null } @@ -228,6 +230,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) + whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.pruneUnsavedContactState(any>()) } @@ -355,28 +358,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `payment requests refresh periodically only while polling is active`() = test { + fun `payment requests refresh immediately and periodically only while polling is active`() = test { isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey whenever { paykitPaymentRequestRepo.refresh() }.thenReturn(Result.success(Unit)) runCurrent() + clearInvocations(paykitPaymentRequestRepo) sut.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) - runCurrent() + try { + runCurrent() - verify(paykitPaymentRequestRepo).refresh() - clearInvocations(paykitPaymentRequestRepo) + verify(paykitPaymentRequestRepo).refresh() + clearInvocations(paykitPaymentRequestRepo) - advanceTimeBy(59.seconds.inWholeMilliseconds) - runCurrent() - verify(paykitPaymentRequestRepo, never()).refresh() + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() - advanceTimeBy(1.seconds.inWholeMilliseconds) - runCurrent() - verify(paykitPaymentRequestRepo).refresh() + verify(paykitPaymentRequestRepo, atLeast(2)).refresh() + clearInvocations(paykitPaymentRequestRepo) + + advanceTimeBy(59.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentRequestRepo, never()).refresh() + + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() + verify(paykitPaymentRequestRepo).refresh() + } finally { + sut.stopPaykitPaymentRequestPolling() + } - sut.stopPaykitPaymentRequestPolling() clearInvocations(paykitPaymentRequestRepo) advanceTimeBy(120.seconds.inWholeMilliseconds) runCurrent() @@ -408,21 +420,24 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() sut.startPaykitPaymentRequestPolling() - advanceTimeBy(30.seconds.inWholeMilliseconds) - runCurrent() - assertNull(sut.currentSheet.value) - verify(privatePaykitRepo).beginPaymentRequest(request) + try { + runCurrent() + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) - advanceTimeBy(29.seconds.inWholeMilliseconds) - runCurrent() - verify(privatePaykitRepo).beginPaymentRequest(request) + advanceTimeBy(29.seconds.inWholeMilliseconds) + runCurrent() + assertNull(sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) - advanceTimeBy(1.seconds.inWholeMilliseconds) - runCurrent() - sut.stopPaykitPaymentRequestPolling() + advanceTimeBy(1.seconds.inWholeMilliseconds) + runCurrent() - verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } finally { + sut.stopPaykitPaymentRequestPolling() + } } @Test diff --git a/changelog.d/next/1141.fixed.md b/changelog.d/next/1141.fixed.md new file mode 100644 index 000000000..c76c161b9 --- /dev/null +++ b/changelog.d/next/1141.fixed.md @@ -0,0 +1 @@ +Fixed delayed private payment requests after adding a Paykit contact or returning to the app.