From ae6ad602a7fed17d3e0dea925de3271a63bd09f4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 14 Sep 2026 15:43:53 +0300 Subject: [PATCH 1/2] fix: streamline paykit foreground sync --- .../to/bitkit/services/PaykitSdkService.kt | 18 ++++++------ .../java/to/bitkit/viewmodels/AppViewModel.kt | 28 +++++++++++++------ .../viewmodels/AppViewModelSendFlowTest.kt | 20 +++++++++++-- changelog.d/next/1255.fixed.md | 1 + gradle/libs.versions.toml | 2 +- 5 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 changelog.d/next/1255.fixed.md diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 141fbd569b..142a1b9328 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -69,12 +69,14 @@ import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic import com.synonym.paykit.requiredSessionCapabilities import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env @@ -908,23 +910,21 @@ class PaykitSdkService @Inject constructor( _backupStateVersion.update { it + 1 } } - @Suppress("TooGenericExceptionCaught") private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { val handle = handle() - val previousRevision = runCatching { handle.stateRevision() }.getOrNull() + val previousRevision = runSuspendCatching { handle.backupStateRevision() }.getOrNull() return try { - block(handle).also { + block(handle) + } finally { + withContext(NonCancellable) { notifyBackupStateChangedIfNeeded(previousRevision, handle) } - } catch (error: Throwable) { - notifyBackupStateChangedIfNeeded(previousRevision, handle) - throw error } } - private fun notifyBackupStateChangedIfNeeded(previousRevision: String?, handle: PaykitSdk) { - val nextRevision = runCatching { handle.stateRevision() }.getOrNull() - if (previousRevision != nextRevision) { + private suspend fun notifyBackupStateChangedIfNeeded(previousRevision: String?, handle: PaykitSdk) { + val nextRevision = runSuspendCatching { handle.backupStateRevision() }.getOrNull() + if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { notifyBackupStateChanged() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 15c183c8b1..46f9c9b9b6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -803,9 +803,9 @@ class AppViewModel @Inject constructor( } } - private suspend fun refreshIncomingPaykitPaymentRequests(): Boolean { + private suspend fun refreshIncomingPaykitPaymentRequests(refreshMaintenance: Boolean = true): Boolean { if (!isPaykitEnabled.value || pubkyRepo.publicKey.value == null || !walletRepo.walletExists()) return false - paykitPaymentProofRepo.reconcile() + if (refreshMaintenance) paykitPaymentProofRepo.reconcile() val previousRequests = paykitPaymentRequestRepo.pendingRequests.value return paykitPaymentRequestRepo.refresh().fold( onSuccess = { @@ -829,11 +829,21 @@ class AppViewModel @Inject constructor( paykitPaymentRequestPollingJob = viewModelScope.launch { var refreshIntervalIndex = 0 + var maintenanceIntervalIndex = 0 + var maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS.first() while (true) { - delay(PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS[refreshIntervalIndex]) - privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") - val requestsChanged = refreshIncomingPaykitPaymentRequests() - refreshPaymentRequestTargets(force = true) + val refreshInterval = PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS[refreshIntervalIndex] + delay(refreshInterval) + maintenanceDelay -= refreshInterval + val refreshMaintenance = maintenanceDelay <= Duration.ZERO + if (refreshMaintenance) { + privatePaykitRepo.refreshKnownSavedContactEndpoints("payment request polling") + maintenanceIntervalIndex = + (maintenanceIntervalIndex + 1).coerceAtMost(PAYKIT_MAINTENANCE_INTERVALS.lastIndex) + maintenanceDelay = PAYKIT_MAINTENANCE_INTERVALS[maintenanceIntervalIndex] + } + val requestsChanged = refreshIncomingPaykitPaymentRequests(refreshMaintenance) + if (refreshMaintenance) refreshPaymentRequestTargets(force = true) refreshIntervalIndex = if (requestsChanged) { 0 } else { @@ -1087,7 +1097,7 @@ class AppViewModel @Inject constructor( } return } else { - PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS.last() + PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_INTERVAL } paymentRequestPresentationRetryAttempts[request.id] = (attempt + 1).coerceAtMost(PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS.size) @@ -5427,9 +5437,11 @@ class AppViewModel @Inject constructor( private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L 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 PAYKIT_PAYMENT_REQUEST_REFRESH_INTERVALS = listOf(5.seconds, 10.seconds, 15.seconds, 30.seconds) + private val PAYKIT_MAINTENANCE_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 = List(14) { 2.seconds } + private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_INTERVAL = 120.seconds private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val BITKIT_SCHEME = "bitkit" diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a232485bfe..cb7d87ca4e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -56,6 +56,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -469,7 +470,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `payment requests refresh immediately and periodically only while polling is active`() = test { + fun `payment requests refresh promptly without repeating maintenance on each poll`() = test { isPaykitEnabled.value = true pubkyPublicKey.value = testPublicKey whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) @@ -488,14 +489,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentRequestRepo, atLeast(2)).refresh() clearInvocations(paykitPaymentRequestRepo) + clearInvocations(privatePaykitRepo, paykitPaymentProofRepo) - advanceTimeBy(59.seconds.inWholeMilliseconds) + advanceTimeBy(29.seconds.inWholeMilliseconds) runCurrent() verify(paykitPaymentRequestRepo, never()).refresh() advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() verify(paykitPaymentRequestRepo).refresh() + verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any()) + verify(paykitPaymentProofRepo, never()).reconcile() + verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), eq(true)) + + advanceTimeBy(30.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any()) + verify(paykitPaymentProofRepo).reconcile() + verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true)) + + advanceTimeBy(120.seconds.inWholeMilliseconds) + runCurrent() + verify(privatePaykitRepo, times(2)).refreshKnownSavedContactEndpoints(any(), any()) + verify(paykitPaymentProofRepo, times(2)).reconcile() } finally { sut.stopPaykitPaymentRequestPolling() } diff --git a/changelog.d/next/1255.fixed.md b/changelog.d/next/1255.fixed.md new file mode 100644 index 0000000000..6d973f81bc --- /dev/null +++ b/changelog.d/next/1255.fixed.md @@ -0,0 +1 @@ +Incoming payment requests appear sooner while the app is open, without unnecessary wallet backup uploads. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 499e78316e..9585ea6ba2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.14" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc51" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc54" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From f030603a3331cb67b6fa237f48cc419efe683359 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 14 Sep 2026 16:03:16 +0300 Subject: [PATCH 2/2] test: cover paykit polling and backup tracking --- .../to/bitkit/services/PaykitSdkService.kt | 35 +++++--- .../services/PaykitBackupStateTrackingTest.kt | 83 +++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 16 +++- 3 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 142a1b9328..5a722d13d3 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -912,20 +912,11 @@ class PaykitSdkService @Inject constructor( private suspend fun withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T { val handle = handle() - val previousRevision = runSuspendCatching { handle.backupStateRevision() }.getOrNull() - return try { + return withPaykitBackupStateTracking( + readRevision = { handle.backupStateRevision() }, + onChange = ::notifyBackupStateChanged, + ) { block(handle) - } finally { - withContext(NonCancellable) { - notifyBackupStateChangedIfNeeded(previousRevision, handle) - } - } - } - - private suspend fun notifyBackupStateChangedIfNeeded(previousRevision: String?, handle: PaykitSdk) { - val nextRevision = runSuspendCatching { handle.backupStateRevision() }.getOrNull() - if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { - notifyBackupStateChanged() } } @@ -989,6 +980,24 @@ class PaykitSdkService @Inject constructor( } } +internal suspend fun withPaykitBackupStateTracking( + readRevision: suspend () -> String, + onChange: () -> Unit, + operation: suspend () -> T, +): T { + val previousRevision = runSuspendCatching { readRevision() }.getOrNull() + return try { + operation() + } finally { + withContext(NonCancellable) { + val nextRevision = runSuspendCatching { readRevision() }.getOrNull() + if (previousRevision == null || nextRevision == null || previousRevision != nextRevision) { + onChange() + } + } + } +} + internal object BitkitPaykitSdkConfig { val clientId: String get() = profileNamespace diff --git a/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt new file mode 100644 index 0000000000..408ab4e501 --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PaykitBackupStateTrackingTest.kt @@ -0,0 +1,83 @@ +package to.bitkit.services + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import org.junit.Test +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PaykitBackupStateTrackingTest : BaseUnitTest() { + @Test + fun `backup decision uses content and treats unreadable revisions conservatively`() = test { + val cases = listOf( + Triple("same", "same", 0), + Triple("before", "after", 1), + Triple(null, "after", 1), + Triple("before", null, 1), + ) + for ((before, after, expectedChanges) in cases) { + val revisions = mutableListOf(before, after) + var changes = 0 + val result = withPaykitBackupStateTracking( + readRevision = { revisions.removeAt(0) ?: throw AppError("Unreadable revision") }, + onChange = { changes++ }, + ) { "result" } + + assertEquals("result", result) + assertEquals(expectedChanges, changes) + assertTrue(revisions.isEmpty()) + } + } + + @Test + fun `partial failure marks changed state and preserves operation error`() = test { + var revision = "before" + var changes = 0 + val failure = AppError("Operation failed") + val thrown = assertFailsWith { + withPaykitBackupStateTracking( + readRevision = { revision }, + onChange = { changes++ }, + ) { + revision = "after" + throw failure + } + } + + assertSame(failure, thrown) + assertEquals(1, changes) + } + + @Test + fun `cancellation after mutation completes backup tracking`() = test { + var revision = "before" + var changes = 0 + val mutated = CompletableDeferred() + val job = launch { + withPaykitBackupStateTracking( + readRevision = { + yield() + revision + }, + onChange = { changes++ }, + ) { + revision = "after" + mutated.complete(Unit) + awaitCancellation() + } + } + mutated.await() + job.cancelAndJoin() + + assertTrue(job.isCancelled) + assertEquals("after", revision) + assertEquals(1, changes) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cb7d87ca4e..7734b3cdd3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -495,6 +495,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { runCurrent() verify(paykitPaymentRequestRepo, never()).refresh() + val request = paymentRequest() + whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + pendingPaykitPaymentRequests.value = listOf(request) + Result.success(Unit) + } advanceTimeBy(1.seconds.inWholeMilliseconds) runCurrent() verify(paykitPaymentRequestRepo).refresh() @@ -502,8 +507,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, never()).reconcile() verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), eq(true)) - advanceTimeBy(30.seconds.inWholeMilliseconds) - runCurrent() + for (delay in listOf(5.seconds, 10.seconds, 15.seconds)) { + clearInvocations(paykitPaymentRequestRepo) + advanceTimeBy(delay.inWholeMilliseconds - 1) + runCurrent() + verify(paykitPaymentRequestRepo, never()).refresh() + advanceTimeBy(1) + runCurrent() + verify(paykitPaymentRequestRepo).refresh() + } verify(privatePaykitRepo).refreshKnownSavedContactEndpoints(any(), any()) verify(paykitPaymentProofRepo).reconcile() verify(paykitPaymentRequestRepo).refreshEligibleTargets(any(), eq(true))