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
41 changes: 25 additions & 16 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -908,24 +910,13 @@ class PaykitSdkService @Inject constructor(
_backupStateVersion.update { it + 1 }
}

@Suppress("TooGenericExceptionCaught")
private suspend fun <T> withStateRevisionTracking(block: suspend (PaykitSdk) -> T): T {
val handle = handle()
val previousRevision = runCatching { handle.stateRevision() }.getOrNull()
return try {
block(handle).also {
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) {
notifyBackupStateChanged()
return withPaykitBackupStateTracking(
readRevision = { handle.backupStateRevision() },
onChange = ::notifyBackupStateChanged,
) {
block(handle)
}
}

Expand Down Expand Up @@ -989,6 +980,24 @@ class PaykitSdkService @Inject constructor(
}
}

internal suspend fun <T> 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
Expand Down
28 changes: 20 additions & 8 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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) {
Comment thread
jvsena42 marked this conversation as resolved.
0
} else {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AppError> {
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<Unit>()
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)
}
}
32 changes: 30 additions & 2 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -488,14 +489,41 @@ class AppViewModelSendFlowTest : BaseUnitTest() {

verify(paykitPaymentRequestRepo, atLeast(2)).refresh()
Comment thread
jvsena42 marked this conversation as resolved.
clearInvocations(paykitPaymentRequestRepo)
clearInvocations(privatePaykitRepo, paykitPaymentProofRepo)

advanceTimeBy(59.seconds.inWholeMilliseconds)
advanceTimeBy(29.seconds.inWholeMilliseconds)
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()
verify(privatePaykitRepo, never()).refreshKnownSavedContactEndpoints(any(), any())
verify(paykitPaymentProofRepo, never()).reconcile()
verify(paykitPaymentRequestRepo, never()).refreshEligibleTargets(any(), eq(true))

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))

advanceTimeBy(120.seconds.inWholeMilliseconds)
runCurrent()
verify(privatePaykitRepo, times(2)).refreshKnownSavedContactEndpoints(any(), any())
verify(paykitPaymentProofRepo, times(2)).reconcile()
} finally {
sut.stopPaykitPaymentRequestPolling()
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1255.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Incoming payment requests appear sooner while the app is open, without unnecessary wallet backup uploads.
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down