Repository navigation
fix: keep wallet backups consistent - #1507
ben-kaufman wants to merge 2 commits into
Conversation
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryPairs each Paykit export with the version read under its operation lock, then checks private state, SDK state, reservations, and identity after capture.
Acknowledged scope:
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Capture wallet state] --> B[Export SDK state and version under operation lock]
B --> C[Capture reservations and payment state]
C --> D{Identity unchanged?}
D -->|No| E[Fail without upload]
D -->|Yes| F{Checked versions unchanged?}
F -->|Yes| G[Encode and upload]
F -->|No| H{Attempts remaining?}
H -->|Yes| A
H -->|No| I[Keep automatic backup pending]
I --> J[Scheduled retry]
J --> A
Reviews (1) · Last reviewed commit: "fix: keep wallet backups progressing dur..." · Reviewed by Greptile |
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Pairs the Paykit SDK export with the backup version read under the same operation lock, rechecks private, SDK, reservation and identity state after capture, and retries a changing wallet snapshot up to three times before deferring it to the status observer. Reviewed f1d1679, full tier. Reasoned from the code and CI; the branch was not built here.
What I checked, and 4 candidates I ruled out
Read: the full diff, BackupRepo.kt from the status observers through getWalletBackupDataBytes, PrivatePaykitRepo.backupSnapshot, PaykitSdkService.exportBackupState, withPaykitBackupStateTracking, resetRuntime and its callers
Call sites traced: PrivatePaykitRepo.backupSnapshot (only BackupRepo), exportBackupState (only PrivatePaykitRepo), persistWalletBackup (UsdtService)
CI: build (which runs testDevDebugUnitTest), build-local, lint, detekt green; the local e2e matrix was still running at review time
Ruled out
- Deferred snapshot never retried:
markBackupFailedclearsfailedBackupRequiredforWalletBackupSnapshotChangedand flipsrunningto false, so the status observer'sdistinctUntilChangedemits andshouldBackupreschedules after the 5 s debounce;automatic backup reschedules snapshot contention without a new required revisioncovers it. - Export version paired with a mutation it does not contain: SDK mutations bump the version inside
operationLock, and the export reads the version afterexportBackupString()under the same lock; a bump outside the lock can only make the final comparison fail, which recaptures rather than uploading. - Cancellation swallowed by the retry loop:
runSuspendCatchingrethrowsCancellationException, so a cancelled backup job does not count as an attempt. - Identity check races setup:
getWalletBackupDataBytesreadsruntimeGenerationbeforeexportBackupStateawaitsisSetup, so aresetRuntime()in setup's stale-session path during a first backup fails it as an identity change, a hard failure that is not deferred. Ruled out narrowly: setup normally finishes before backups start observing, and a failure there waits for the next wallet change the same way any other wallet backup failure does on master.
Merge confidence: 4/5, the setup-race candidate above was ruled out only narrowly, and the e2e matrix had not finished.
There was a problem hiding this comment.
Verdict: ✅ Approve
QA for f1d1679:
Tests for the review: Tests 1, 3 and 4 pass.
Tested on Android 16 emulator (Pixel 10 Pro).
🟢 Tests 1, 2, 3, 4
Test 1
On f1d1679 + test hook: the first uploaded capture includes the queued SDK contact mutation.
1.2x.mp4 |
test hook
Not pushed. Holds genuine SDK contact mutation, wallet capture and VSS upload until explicit file release; retains actual uploaded bytes and logs SDK versions and capture/upload counts.
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index b3ea6df74..f05b68896 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -568,6 +568,74 @@ class BackupRepo @Inject constructor(
}
}
+ internal fun snapshotQa(command: String) { // test hook
+ if (!to.bitkit.BuildConfig.DEBUG) return // test hook
+ if (command == "cancel") { to.bitkit.utils.SnapshotQa.job?.cancel(); return } // test hook
+ to.bitkit.utils.SnapshotQa.job = scope.launch { // test hook
+ runSuspendCatching { // test hook
+ when { // test hook
+ command.startsWith("consume:") -> privatePaykitRepo.get().snapshotQaConsume(command.substringAfter(':').toULong()) // test hook
+ else -> when (command) { // test hook
+ "address" -> { // test hook
+ val address = lightningService.newAddress() // test hook
+ java.io.File(requireNotNull(to.bitkit.utils.SnapshotQa.root), "funding-address").writeText(address) // test hook
+ to.bitkit.utils.SnapshotQa.log("funding address=$address") // test hook
+ } // test hook
+ "seed4" -> { // test hook
+ stopObservingBackups() // test hook
+ val identity = requireNotNull(to.bitkit.models.PubkyPublicKeyFormat.normalized(requireNotNull(pubkyRepo.publicKey.value))) // test hook
+ privatePaykitRepo.get().snapshotQaConsume(104uL) // test hook
+ val address = privatePaykitAddressReservationRepo.get().currentOrRotatedAddress(identity).getOrThrow() // test hook
+ val request = PaykitPaymentRequestId(java.util.UUID.randomUUID().toString(), identity) // test hook
+ val attempt = onchainSendAttemptStore.admit(WalletScope.default, request, null, address, 1000uL, false, 1uL, false, null, emptyList(), beforeSendAttempt = {}, payerIdentity = identity) // test hook
+ val prepared = lightningService.prepareOnchainSend(address, 1000uL, 1uL, walletIndex = attempt.walletIndex) // test hook
+ val signed = onchainSendAttemptStore.retainPreparedReceipt(attempt.attemptId, attempt.walletIndex, prepared.receipt.copy(feeRateSatsPerVByte = 1uL), false) // test hook
+ val proof = PendingPaykitPaymentProof(identity, request, "bitcoin-onchain", "snapshot-qa", PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = signed.txid, proofData = signed.txid, onchainAddress = address, onchainAmountSats = 1000uL, privatePaymentListVersion = 104uL, previousPrivatePaymentListVersion = 103uL) // test hook
+ paykitPaymentProofRepo.get().restoreBackup(listOf(PaykitPaymentStateBackup.Proof(proof))) // test hook
+ to.bitkit.utils.SnapshotQa.log("seed4 signed txid=${signed.txid} inputs=${signed.originalInputs?.size} proofs=1 consumption=104 reservations=${privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow()} dispatch=false") // test hook
+ persistWalletBackup().getOrThrow() // test hook
+ to.bitkit.utils.SnapshotQa.log("seed4 captured-uploaded") // test hook
+ } // test hook
+ "restore4" -> { // test hook
+ stopObservingBackups() // test hook
+ val root = requireNotNull(to.bitkit.utils.SnapshotQa.root) // test hook
+ val bytes = java.io.File(root, "fixture-envelope.json").readBytes() // test hook
+ val before = json.decodeFromString<WalletBackupV1>(String(bytes)) // test hook
+ privatePaykitRepo.get().snapshotQaConsume(999uL) // test hook
+ privatePaykitAddressReservationRepo.get().restoreBackup(null).getOrThrow() // test hook
+ paykitPaymentProofRepo.get().restoreBackup(emptyList()) // test hook
+ keychain.delete(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name, lightningService.currentWalletIndex) // test hook
+ check(onchainSendAttemptStore.current() == null) // test hook
+ to.bitkit.utils.SnapshotQa.log("restore4 fixture cleared proofs=0 reservations=null attempt=null consumption=999") // test hook
+ restoreWalletBackup(bytes) // test hook
+ val privateState = privatePaykitRepo.get().backupSnapshot().getOrThrow().state // test hook
+ val expectedPrivate = requireNotNull(before.paykitSdkBackupState) // test hook
+ val actualEnvelope = json.parseToJsonElement(requireNotNull(privateState)) as kotlinx.serialization.json.JsonObject // test hook
+ val expectedEnvelope = json.parseToJsonElement(expectedPrivate) as kotlinx.serialization.json.JsonObject // test hook
+ check(actualEnvelope["consumedPrivatePaymentListVersions"] == expectedEnvelope["consumedPrivatePaymentListVersions"]) // test hook
+ root.resolve("restored-private.json").writeText(privateState) // test hook
+ val privateEnvelope = json.parseToJsonElement(expectedPrivate).let { it as kotlinx.serialization.json.JsonObject } // test hook
+ val sdk = (privateEnvelope.getValue("sdkState") as kotlinx.serialization.json.JsonPrimitive).content // test hook
+ check(keychain.loadString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name) == sdk) // test hook
+ check(privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() == before.privatePaykitHighestReservedReceiveIndexByAddressType) // test hook
+ check(paykitPaymentProofRepo.get().backupSnapshot() == requireNotNull(before.paykitPaymentState).pendingProofs) // test hook
+ val restored = requireNotNull(onchainSendAttemptStore.current()) // test hook
+ val wire = ActiveOnchainAttemptBackup.from(restored, Env.network.name.lowercase(), vssStoreIdProvider.getBackupWalletBinding(restored.walletIndex)) // test hook
+ check(wire == before.paykitPaymentState?.activeOnchainAttempt) // test hook
+ check(!restored.hasPositiveEvidence) // test hook
+ to.bitkit.utils.SnapshotQa.log("restore4 verified sdk=true private=true reservations=true proofs=true signedAttempt=true restored=${restored.restoredFromBackup} txid=${restored.txid} dispatch=false") // test hook
+ } // test hook
+ "stop" -> stopObservingBackups() // test hook
+ "start" -> startObservingBackups() // test hook
+ "backup" -> triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ "required" -> markBackupRequired(BackupCategory.WALLET) // test hook
+ "status" -> to.bitkit.utils.SnapshotQa.log("status=${cacheStore.backupStatuses.first()[BackupCategory.WALLET]}") // test hook
+ } // test hook
+ } // test hook
+ }.onSuccess { to.bitkit.utils.SnapshotQa.log("command=$command success") }.onFailure { android.util.Log.e("SnapshotQa", "command=$command failure", it) } // test hook
+ } // test hook
+ } // test hook
+
suspend fun persistWalletBackup(usdt: String? = null): Result<Unit> =
uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) }
@@ -577,7 +645,13 @@ class BackupRepo @Inject constructor(
if (shouldSkipBackup()) throw UsdtException.BackupUnavailable()
vssBackupClient.setup().getOrThrow()
val data = getWalletBackupDataBytes(usdt)
+ val qa = to.bitkit.utils.SnapshotQa // test hook
+ qa.root?.let { java.io.File(it, "candidate-${qa.captures}.json").writeBytes(data) } // test hook
+ qa.gate("upload-${qa.captures}") // test hook
vssBackupClient.putObject(key = BackupCategory.WALLET.name, data = data).getOrThrow()
+ qa.uploads++ // test hook
+ qa.root?.let { java.io.File(it, "uploaded-${qa.uploads}.json").writeBytes(data) } // test hook
+ qa.log("uploaded=${qa.uploads} capture=${qa.captures} bytes=${data.size}") // test hook
}
}.map { }
}
@@ -705,6 +779,8 @@ class BackupRepo @Inject constructor(
"Wallet backup restore is incomplete"
}
lateinit var transfers: List<TransferEntity>
+ to.bitkit.utils.SnapshotQa.captures++ // test hook
+ to.bitkit.utils.SnapshotQa.log("capture=${to.bitkit.utils.SnapshotQa.captures} start generation=$sdkGeneration") // test hook
val privateRepo = privatePaykitRepo.get()
val privateVersion = privateRepo.backupStateVersion.value
val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow()
@@ -754,6 +830,8 @@ class BackupRepo @Inject constructor(
),
)
+ to.bitkit.utils.SnapshotQa.gate("capture-${to.bitkit.utils.SnapshotQa.captures}") // test hook
+ to.bitkit.utils.SnapshotQa.log("capture=${to.bitkit.utils.SnapshotQa.captures} final private=$privateVersion/${privateRepo.backupStateVersion.value} sdk=${paykitSdkBackupState.sdkVersion}/${paykitSdkService.backupStateVersion.value}") // test hook
check(paykitSdkService.runtimeGeneration == sdkGeneration) {
"Paykit identity changed during wallet snapshot"
}
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index 0b4759a49..becdef091 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -697,6 +697,13 @@ class PrivatePaykitRepo @Inject constructor(
}
}
+ internal suspend fun snapshotQaConsume(version: ULong) = withContext(serializedDispatcher) { // test hook
+ val key = requireNotNull(pubkyService.currentPublicKey()) // test hook
+ ensureState().contacts.getOrPut(key) { ContactState() }.consumedPrivatePaymentListVersion = version // test hook
+ persistState(markWalletBackup = true) // test hook
+ to.bitkit.utils.SnapshotQa.log("private version=$version revision=${backupStateVersion.value}") // test hook
+ } // test hook
+
internal suspend fun backupSnapshot(): Result<PrivatePaykitBackupSnapshot> =
withContext(serializedDispatcher) {
runSuspendCatching {
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index cca86961c..a50d319b9 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -1307,6 +1307,7 @@ class PaykitSdkService @Inject constructor(
check(runtimeGeneration == generation) { "Paykit runtime changed before backup export" }
if (priority == Priority.Background && isPaymentSubmissionActive.value) return@withLock null
val state = withPaykitKey { completeSdkCall { it.exportBackupString() } }
+ to.bitkit.utils.SnapshotQa.log("export version=${backupStateVersion.value} bytes=${state.length}") // test hook
PaykitSdkBackupExport(state, backupStateVersion.value)
}
} while (backup == null)
@@ -1314,6 +1315,18 @@ class PaykitSdkService @Inject constructor(
}
}
+ internal suspend fun snapshotQaMutate(label: String) { // test hook
+ isSetup.await() // test hook
+ operationLock.withLock { // test hook
+ to.bitkit.utils.SnapshotQa.gate("mutation") // test hook
+ withStateRevisionTracking { handle -> // test hook
+ val key = pubkyPublicKeyFromSecret(PubkyLocalSecretKey(ByteArray(32) { 7 })) // test hook
+ completeSdkCall { handle.saveContactsAndUnblockPeers(listOf(ContactUpdate(key, label))) } // test hook
+ } // test hook
+ to.bitkit.utils.SnapshotQa.log("mutation label=$label version=${backupStateVersion.value}") // test hook
+ } // test hook
+ } // test hook
+
suspend fun retainRecoveryBackup(backup: String) {
keychain.upsertString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name, backup)
}
diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt
index 026af2984..04d3201b1 100644
--- a/app/src/main/java/to/bitkit/ui/MainActivity.kt
+++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt
@@ -94,6 +94,9 @@ class MainActivity : FragmentActivity() {
@Inject
lateinit var hwWalletRepo: HwWalletRepo
+ @Inject lateinit var snapshotBackupRepo: to.bitkit.repositories.BackupRepo // test hook
+ @Inject lateinit var snapshotSdk: to.bitkit.services.PaykitSdkService // test hook
+
private val appViewModel by viewModels<AppViewModel>()
private val walletViewModel by viewModels<WalletViewModel>()
private val blocktankViewModel by viewModels<BlocktankViewModel>()
@@ -106,6 +109,7 @@ class MainActivity : FragmentActivity() {
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ if (to.bitkit.BuildConfig.DEBUG) to.bitkit.utils.SnapshotQa.root = java.io.File(filesDir, "snapshot-qa").apply { mkdirs() } // test hook
initNotificationChannel()
initNotificationChannel(
@@ -289,6 +293,16 @@ class MainActivity : FragmentActivity() {
}
private fun handleLaunchIntent(intent: Intent) {
+ if (to.bitkit.BuildConfig.DEBUG && intent.hasExtra("snapshot-qa")) { // test hook
+ val command = intent.getStringExtra("snapshot-qa").orEmpty() // test hook
+ if (command.startsWith("mutate:")) { // test hook
+ kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { // test hook
+ to.bitkit.ext.runSuspendCatching { snapshotSdk.snapshotQaMutate(command.substringAfter(':')) }.onFailure { android.util.Log.e("SnapshotQa", "mutation failure", it) } // test hook
+ } // test hook
+ } else snapshotBackupRepo.snapshotQa(command) // test hook
+ intent.removeExtra("snapshot-qa") // test hook
+ return // test hook
+ } // test hook
if (intent.getBooleanExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE, false)) {
intent.removeExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE)
appViewModel.onPaykitSubscriptionNotificationTapped(
diff --git a/app/src/main/java/to/bitkit/utils/SnapshotQa.kt b/app/src/main/java/to/bitkit/utils/SnapshotQa.kt
new file mode 100644
index 000000000..43c71c074
--- /dev/null
+++ b/app/src/main/java/to/bitkit/utils/SnapshotQa.kt
@@ -0,0 +1,21 @@
+package to.bitkit.utils
+
+import android.util.Log
+import java.io.File
+import kotlinx.coroutines.delay
+import to.bitkit.BuildConfig
+
+internal object SnapshotQa { // test hook
+ var root: File? = null // test hook
+ var job: kotlinx.coroutines.Job? = null // test hook
+ var captures = 0 // test hook
+ var uploads = 0 // test hook
+ fun log(value: String) { if (BuildConfig.DEBUG) Log.i("SnapshotQa", value) } // test hook
+ suspend fun gate(name: String) { // test hook
+ if (!BuildConfig.DEBUG) return // test hook
+ val dir = root ?: return // test hook
+ log("gate $name") // test hook
+ while (File(dir, "hold-${name.substringBefore('-')}").exists() && !File(dir, "release-$name").exists()) delay(100) // test hook
+ log("release $name") // test hook
+ } // test hook
+} // test hookTest 2
Held captures 3, 4 and 5 after SDK export and advanced the real private consumption version to 101, 102 and 103; none uploaded and the wallet stayed pending. After removing the hold, automatic capture 6 uploaded the envelope containing version 103, and synced matched required. The controlled holds are not user waits, and the test requires internal gates rather than a UI journey. The recording shows the app context; device logs and retained uploaded bytes decide the internal checks.
Ran on f1d1679.
Test 3
On f1d1679 + test hook: newer private state stayed pending after the held upload; a cancelled capture never uploaded.
3.2x.mp4 |
test hook
Not pushed. Holds actual wallet upload/capture, writes a private consumption version and pending revision, cancels the real capture coroutine, and retains real uploaded bytes.
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index b3ea6df74..f05b68896 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -568,6 +568,74 @@ class BackupRepo @Inject constructor(
}
}
+ internal fun snapshotQa(command: String) { // test hook
+ if (!to.bitkit.BuildConfig.DEBUG) return // test hook
+ if (command == "cancel") { to.bitkit.utils.SnapshotQa.job?.cancel(); return } // test hook
+ to.bitkit.utils.SnapshotQa.job = scope.launch { // test hook
+ runSuspendCatching { // test hook
+ when { // test hook
+ command.startsWith("consume:") -> privatePaykitRepo.get().snapshotQaConsume(command.substringAfter(':').toULong()) // test hook
+ else -> when (command) { // test hook
+ "address" -> { // test hook
+ val address = lightningService.newAddress() // test hook
+ java.io.File(requireNotNull(to.bitkit.utils.SnapshotQa.root), "funding-address").writeText(address) // test hook
+ to.bitkit.utils.SnapshotQa.log("funding address=$address") // test hook
+ } // test hook
+ "seed4" -> { // test hook
+ stopObservingBackups() // test hook
+ val identity = requireNotNull(to.bitkit.models.PubkyPublicKeyFormat.normalized(requireNotNull(pubkyRepo.publicKey.value))) // test hook
+ privatePaykitRepo.get().snapshotQaConsume(104uL) // test hook
+ val address = privatePaykitAddressReservationRepo.get().currentOrRotatedAddress(identity).getOrThrow() // test hook
+ val request = PaykitPaymentRequestId(java.util.UUID.randomUUID().toString(), identity) // test hook
+ val attempt = onchainSendAttemptStore.admit(WalletScope.default, request, null, address, 1000uL, false, 1uL, false, null, emptyList(), beforeSendAttempt = {}, payerIdentity = identity) // test hook
+ val prepared = lightningService.prepareOnchainSend(address, 1000uL, 1uL, walletIndex = attempt.walletIndex) // test hook
+ val signed = onchainSendAttemptStore.retainPreparedReceipt(attempt.attemptId, attempt.walletIndex, prepared.receipt.copy(feeRateSatsPerVByte = 1uL), false) // test hook
+ val proof = PendingPaykitPaymentProof(identity, request, "bitcoin-onchain", "snapshot-qa", PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = signed.txid, proofData = signed.txid, onchainAddress = address, onchainAmountSats = 1000uL, privatePaymentListVersion = 104uL, previousPrivatePaymentListVersion = 103uL) // test hook
+ paykitPaymentProofRepo.get().restoreBackup(listOf(PaykitPaymentStateBackup.Proof(proof))) // test hook
+ to.bitkit.utils.SnapshotQa.log("seed4 signed txid=${signed.txid} inputs=${signed.originalInputs?.size} proofs=1 consumption=104 reservations=${privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow()} dispatch=false") // test hook
+ persistWalletBackup().getOrThrow() // test hook
+ to.bitkit.utils.SnapshotQa.log("seed4 captured-uploaded") // test hook
+ } // test hook
+ "restore4" -> { // test hook
+ stopObservingBackups() // test hook
+ val root = requireNotNull(to.bitkit.utils.SnapshotQa.root) // test hook
+ val bytes = java.io.File(root, "fixture-envelope.json").readBytes() // test hook
+ val before = json.decodeFromString<WalletBackupV1>(String(bytes)) // test hook
+ privatePaykitRepo.get().snapshotQaConsume(999uL) // test hook
+ privatePaykitAddressReservationRepo.get().restoreBackup(null).getOrThrow() // test hook
+ paykitPaymentProofRepo.get().restoreBackup(emptyList()) // test hook
+ keychain.delete(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name, lightningService.currentWalletIndex) // test hook
+ check(onchainSendAttemptStore.current() == null) // test hook
+ to.bitkit.utils.SnapshotQa.log("restore4 fixture cleared proofs=0 reservations=null attempt=null consumption=999") // test hook
+ restoreWalletBackup(bytes) // test hook
+ val privateState = privatePaykitRepo.get().backupSnapshot().getOrThrow().state // test hook
+ val expectedPrivate = requireNotNull(before.paykitSdkBackupState) // test hook
+ val actualEnvelope = json.parseToJsonElement(requireNotNull(privateState)) as kotlinx.serialization.json.JsonObject // test hook
+ val expectedEnvelope = json.parseToJsonElement(expectedPrivate) as kotlinx.serialization.json.JsonObject // test hook
+ check(actualEnvelope["consumedPrivatePaymentListVersions"] == expectedEnvelope["consumedPrivatePaymentListVersions"]) // test hook
+ root.resolve("restored-private.json").writeText(privateState) // test hook
+ val privateEnvelope = json.parseToJsonElement(expectedPrivate).let { it as kotlinx.serialization.json.JsonObject } // test hook
+ val sdk = (privateEnvelope.getValue("sdkState") as kotlinx.serialization.json.JsonPrimitive).content // test hook
+ check(keychain.loadString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name) == sdk) // test hook
+ check(privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() == before.privatePaykitHighestReservedReceiveIndexByAddressType) // test hook
+ check(paykitPaymentProofRepo.get().backupSnapshot() == requireNotNull(before.paykitPaymentState).pendingProofs) // test hook
+ val restored = requireNotNull(onchainSendAttemptStore.current()) // test hook
+ val wire = ActiveOnchainAttemptBackup.from(restored, Env.network.name.lowercase(), vssStoreIdProvider.getBackupWalletBinding(restored.walletIndex)) // test hook
+ check(wire == before.paykitPaymentState?.activeOnchainAttempt) // test hook
+ check(!restored.hasPositiveEvidence) // test hook
+ to.bitkit.utils.SnapshotQa.log("restore4 verified sdk=true private=true reservations=true proofs=true signedAttempt=true restored=${restored.restoredFromBackup} txid=${restored.txid} dispatch=false") // test hook
+ } // test hook
+ "stop" -> stopObservingBackups() // test hook
+ "start" -> startObservingBackups() // test hook
+ "backup" -> triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ "required" -> markBackupRequired(BackupCategory.WALLET) // test hook
+ "status" -> to.bitkit.utils.SnapshotQa.log("status=${cacheStore.backupStatuses.first()[BackupCategory.WALLET]}") // test hook
+ } // test hook
+ } // test hook
+ }.onSuccess { to.bitkit.utils.SnapshotQa.log("command=$command success") }.onFailure { android.util.Log.e("SnapshotQa", "command=$command failure", it) } // test hook
+ } // test hook
+ } // test hook
+
suspend fun persistWalletBackup(usdt: String? = null): Result<Unit> =
uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) }
@@ -577,7 +645,13 @@ class BackupRepo @Inject constructor(
if (shouldSkipBackup()) throw UsdtException.BackupUnavailable()
vssBackupClient.setup().getOrThrow()
val data = getWalletBackupDataBytes(usdt)
+ val qa = to.bitkit.utils.SnapshotQa // test hook
+ qa.root?.let { java.io.File(it, "candidate-${qa.captures}.json").writeBytes(data) } // test hook
+ qa.gate("upload-${qa.captures}") // test hook
vssBackupClient.putObject(key = BackupCategory.WALLET.name, data = data).getOrThrow()
+ qa.uploads++ // test hook
+ qa.root?.let { java.io.File(it, "uploaded-${qa.uploads}.json").writeBytes(data) } // test hook
+ qa.log("uploaded=${qa.uploads} capture=${qa.captures} bytes=${data.size}") // test hook
}
}.map { }
}
@@ -705,6 +779,8 @@ class BackupRepo @Inject constructor(
"Wallet backup restore is incomplete"
}
lateinit var transfers: List<TransferEntity>
+ to.bitkit.utils.SnapshotQa.captures++ // test hook
+ to.bitkit.utils.SnapshotQa.log("capture=${to.bitkit.utils.SnapshotQa.captures} start generation=$sdkGeneration") // test hook
val privateRepo = privatePaykitRepo.get()
val privateVersion = privateRepo.backupStateVersion.value
val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow()
@@ -754,6 +830,8 @@ class BackupRepo @Inject constructor(
),
)
+ to.bitkit.utils.SnapshotQa.gate("capture-${to.bitkit.utils.SnapshotQa.captures}") // test hook
+ to.bitkit.utils.SnapshotQa.log("capture=${to.bitkit.utils.SnapshotQa.captures} final private=$privateVersion/${privateRepo.backupStateVersion.value} sdk=${paykitSdkBackupState.sdkVersion}/${paykitSdkService.backupStateVersion.value}") // test hook
check(paykitSdkService.runtimeGeneration == sdkGeneration) {
"Paykit identity changed during wallet snapshot"
}
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index 0b4759a49..becdef091 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -697,6 +697,13 @@ class PrivatePaykitRepo @Inject constructor(
}
}
+ internal suspend fun snapshotQaConsume(version: ULong) = withContext(serializedDispatcher) { // test hook
+ val key = requireNotNull(pubkyService.currentPublicKey()) // test hook
+ ensureState().contacts.getOrPut(key) { ContactState() }.consumedPrivatePaymentListVersion = version // test hook
+ persistState(markWalletBackup = true) // test hook
+ to.bitkit.utils.SnapshotQa.log("private version=$version revision=${backupStateVersion.value}") // test hook
+ } // test hook
+
internal suspend fun backupSnapshot(): Result<PrivatePaykitBackupSnapshot> =
withContext(serializedDispatcher) {
runSuspendCatching {
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index cca86961c..a50d319b9 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -1307,6 +1307,7 @@ class PaykitSdkService @Inject constructor(
check(runtimeGeneration == generation) { "Paykit runtime changed before backup export" }
if (priority == Priority.Background && isPaymentSubmissionActive.value) return@withLock null
val state = withPaykitKey { completeSdkCall { it.exportBackupString() } }
+ to.bitkit.utils.SnapshotQa.log("export version=${backupStateVersion.value} bytes=${state.length}") // test hook
PaykitSdkBackupExport(state, backupStateVersion.value)
}
} while (backup == null)
@@ -1314,6 +1315,18 @@ class PaykitSdkService @Inject constructor(
}
}
+ internal suspend fun snapshotQaMutate(label: String) { // test hook
+ isSetup.await() // test hook
+ operationLock.withLock { // test hook
+ to.bitkit.utils.SnapshotQa.gate("mutation") // test hook
+ withStateRevisionTracking { handle -> // test hook
+ val key = pubkyPublicKeyFromSecret(PubkyLocalSecretKey(ByteArray(32) { 7 })) // test hook
+ completeSdkCall { handle.saveContactsAndUnblockPeers(listOf(ContactUpdate(key, label))) } // test hook
+ } // test hook
+ to.bitkit.utils.SnapshotQa.log("mutation label=$label version=${backupStateVersion.value}") // test hook
+ } // test hook
+ } // test hook
+
suspend fun retainRecoveryBackup(backup: String) {
keychain.upsertString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name, backup)
}
diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt
index 026af2984..04d3201b1 100644
--- a/app/src/main/java/to/bitkit/ui/MainActivity.kt
+++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt
@@ -94,6 +94,9 @@ class MainActivity : FragmentActivity() {
@Inject
lateinit var hwWalletRepo: HwWalletRepo
+ @Inject lateinit var snapshotBackupRepo: to.bitkit.repositories.BackupRepo // test hook
+ @Inject lateinit var snapshotSdk: to.bitkit.services.PaykitSdkService // test hook
+
private val appViewModel by viewModels<AppViewModel>()
private val walletViewModel by viewModels<WalletViewModel>()
private val blocktankViewModel by viewModels<BlocktankViewModel>()
@@ -106,6 +109,7 @@ class MainActivity : FragmentActivity() {
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ if (to.bitkit.BuildConfig.DEBUG) to.bitkit.utils.SnapshotQa.root = java.io.File(filesDir, "snapshot-qa").apply { mkdirs() } // test hook
initNotificationChannel()
initNotificationChannel(
@@ -289,6 +293,16 @@ class MainActivity : FragmentActivity() {
}
private fun handleLaunchIntent(intent: Intent) {
+ if (to.bitkit.BuildConfig.DEBUG && intent.hasExtra("snapshot-qa")) { // test hook
+ val command = intent.getStringExtra("snapshot-qa").orEmpty() // test hook
+ if (command.startsWith("mutate:")) { // test hook
+ kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { // test hook
+ to.bitkit.ext.runSuspendCatching { snapshotSdk.snapshotQaMutate(command.substringAfter(':')) }.onFailure { android.util.Log.e("SnapshotQa", "mutation failure", it) } // test hook
+ } // test hook
+ } else snapshotBackupRepo.snapshotQa(command) // test hook
+ intent.removeExtra("snapshot-qa") // test hook
+ return // test hook
+ } // test hook
if (intent.getBooleanExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE, false)) {
intent.removeExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE)
appViewModel.onPaykitSubscriptionNotificationTapped(
diff --git a/app/src/main/java/to/bitkit/utils/SnapshotQa.kt b/app/src/main/java/to/bitkit/utils/SnapshotQa.kt
new file mode 100644
index 000000000..43c71c074
--- /dev/null
+++ b/app/src/main/java/to/bitkit/utils/SnapshotQa.kt
@@ -0,0 +1,21 @@
+package to.bitkit.utils
+
+import android.util.Log
+import java.io.File
+import kotlinx.coroutines.delay
+import to.bitkit.BuildConfig
+
+internal object SnapshotQa { // test hook
+ var root: File? = null // test hook
+ var job: kotlinx.coroutines.Job? = null // test hook
+ var captures = 0 // test hook
+ var uploads = 0 // test hook
+ fun log(value: String) { if (BuildConfig.DEBUG) Log.i("SnapshotQa", value) } // test hook
+ suspend fun gate(name: String) { // test hook
+ if (!BuildConfig.DEBUG) return // test hook
+ val dir = root ?: return // test hook
+ log("gate $name") // test hook
+ while (File(dir, "hold-${name.substringBefore('-')}").exists() && !File(dir, "release-$name").exists()) delay(100) // test hook
+ log("release $name") // test hook
+ } // test hook
+} // test hookTest 4
On f1d1679 + test hook: retained SDK bytes and restored private state, reservations, proof and signed attempt agree without broadcast; round 2 passed after correcting the earlier live-export comparison.
4.2x.mp4 |
test hook
Not pushed. Prepares and retains a genuine signed transaction without broadcasting, captures one proof plus a private consumption version and reservation, clears only isolated test state, invokes the normal envelope restore, and compares restored stores.
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index b3ea6df74..f05b68896 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -568,6 +568,74 @@ class BackupRepo @Inject constructor(
}
}
+ internal fun snapshotQa(command: String) { // test hook
+ if (!to.bitkit.BuildConfig.DEBUG) return // test hook
+ if (command == "cancel") { to.bitkit.utils.SnapshotQa.job?.cancel(); return } // test hook
+ to.bitkit.utils.SnapshotQa.job = scope.launch { // test hook
+ runSuspendCatching { // test hook
+ when { // test hook
+ command.startsWith("consume:") -> privatePaykitRepo.get().snapshotQaConsume(command.substringAfter(':').toULong()) // test hook
+ else -> when (command) { // test hook
+ "address" -> { // test hook
+ val address = lightningService.newAddress() // test hook
+ java.io.File(requireNotNull(to.bitkit.utils.SnapshotQa.root), "funding-address").writeText(address) // test hook
+ to.bitkit.utils.SnapshotQa.log("funding address=$address") // test hook
+ } // test hook
+ "seed4" -> { // test hook
+ stopObservingBackups() // test hook
+ val identity = requireNotNull(to.bitkit.models.PubkyPublicKeyFormat.normalized(requireNotNull(pubkyRepo.publicKey.value))) // test hook
+ privatePaykitRepo.get().snapshotQaConsume(104uL) // test hook
+ val address = privatePaykitAddressReservationRepo.get().currentOrRotatedAddress(identity).getOrThrow() // test hook
+ val request = PaykitPaymentRequestId(java.util.UUID.randomUUID().toString(), identity) // test hook
+ val attempt = onchainSendAttemptStore.admit(WalletScope.default, request, null, address, 1000uL, false, 1uL, false, null, emptyList(), beforeSendAttempt = {}, payerIdentity = identity) // test hook
+ val prepared = lightningService.prepareOnchainSend(address, 1000uL, 1uL, walletIndex = attempt.walletIndex) // test hook
+ val signed = onchainSendAttemptStore.retainPreparedReceipt(attempt.attemptId, attempt.walletIndex, prepared.receipt.copy(feeRateSatsPerVByte = 1uL), false) // test hook
+ val proof = PendingPaykitPaymentProof(identity, request, "bitcoin-onchain", "snapshot-qa", PaykitPaymentProofKind.Onchain, paymentStarted = true, paymentIdentifier = signed.txid, proofData = signed.txid, onchainAddress = address, onchainAmountSats = 1000uL, privatePaymentListVersion = 104uL, previousPrivatePaymentListVersion = 103uL) // test hook
+ paykitPaymentProofRepo.get().restoreBackup(listOf(PaykitPaymentStateBackup.Proof(proof))) // test hook
+ to.bitkit.utils.SnapshotQa.log("seed4 signed txid=${signed.txid} inputs=${signed.originalInputs?.size} proofs=1 consumption=104 reservations=${privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow()} dispatch=false") // test hook
+ persistWalletBackup().getOrThrow() // test hook
+ to.bitkit.utils.SnapshotQa.log("seed4 captured-uploaded") // test hook
+ } // test hook
+ "restore4" -> { // test hook
+ stopObservingBackups() // test hook
+ val root = requireNotNull(to.bitkit.utils.SnapshotQa.root) // test hook
+ val bytes = java.io.File(root, "fixture-envelope.json").readBytes() // test hook
+ val before = json.decodeFromString<WalletBackupV1>(String(bytes)) // test hook
+ privatePaykitRepo.get().snapshotQaConsume(999uL) // test hook
+ privatePaykitAddressReservationRepo.get().restoreBackup(null).getOrThrow() // test hook
+ paykitPaymentProofRepo.get().restoreBackup(emptyList()) // test hook
+ keychain.delete(Keychain.Key.ONCHAIN_SEND_ATTEMPT.name, lightningService.currentWalletIndex) // test hook
+ check(onchainSendAttemptStore.current() == null) // test hook
+ to.bitkit.utils.SnapshotQa.log("restore4 fixture cleared proofs=0 reservations=null attempt=null consumption=999") // test hook
+ restoreWalletBackup(bytes) // test hook
+ val privateState = privatePaykitRepo.get().backupSnapshot().getOrThrow().state // test hook
+ val expectedPrivate = requireNotNull(before.paykitSdkBackupState) // test hook
+ val actualEnvelope = json.parseToJsonElement(requireNotNull(privateState)) as kotlinx.serialization.json.JsonObject // test hook
+ val expectedEnvelope = json.parseToJsonElement(expectedPrivate) as kotlinx.serialization.json.JsonObject // test hook
+ check(actualEnvelope["consumedPrivatePaymentListVersions"] == expectedEnvelope["consumedPrivatePaymentListVersions"]) // test hook
+ root.resolve("restored-private.json").writeText(privateState) // test hook
+ val privateEnvelope = json.parseToJsonElement(expectedPrivate).let { it as kotlinx.serialization.json.JsonObject } // test hook
+ val sdk = (privateEnvelope.getValue("sdkState") as kotlinx.serialization.json.JsonPrimitive).content // test hook
+ check(keychain.loadString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name) == sdk) // test hook
+ check(privatePaykitAddressReservationRepo.get().backupSnapshot().getOrThrow() == before.privatePaykitHighestReservedReceiveIndexByAddressType) // test hook
+ check(paykitPaymentProofRepo.get().backupSnapshot() == requireNotNull(before.paykitPaymentState).pendingProofs) // test hook
+ val restored = requireNotNull(onchainSendAttemptStore.current()) // test hook
+ val wire = ActiveOnchainAttemptBackup.from(restored, Env.network.name.lowercase(), vssStoreIdProvider.getBackupWalletBinding(restored.walletIndex)) // test hook
+ check(wire == before.paykitPaymentState?.activeOnchainAttempt) // test hook
+ check(!restored.hasPositiveEvidence) // test hook
+ to.bitkit.utils.SnapshotQa.log("restore4 verified sdk=true private=true reservations=true proofs=true signedAttempt=true restored=${restored.restoredFromBackup} txid=${restored.txid} dispatch=false") // test hook
+ } // test hook
+ "stop" -> stopObservingBackups() // test hook
+ "start" -> startObservingBackups() // test hook
+ "backup" -> triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ "required" -> markBackupRequired(BackupCategory.WALLET) // test hook
+ "status" -> to.bitkit.utils.SnapshotQa.log("status=${cacheStore.backupStatuses.first()[BackupCategory.WALLET]}") // test hook
+ } // test hook
+ } // test hook
+ }.onSuccess { to.bitkit.utils.SnapshotQa.log("command=$command success") }.onFailure { android.util.Log.e("SnapshotQa", "command=$command failure", it) } // test hook
+ } // test hook
+ } // test hook
+
suspend fun persistWalletBackup(usdt: String? = null): Result<Unit> =
uploadWalletBackup(usdt).onFailure { markBackupRequired(BackupCategory.WALLET) }
@@ -577,7 +645,13 @@ class BackupRepo @Inject constructor(
if (shouldSkipBackup()) throw UsdtException.BackupUnavailable()
vssBackupClient.setup().getOrThrow()
val data = getWalletBackupDataBytes(usdt)
+ val qa = to.bitkit.utils.SnapshotQa // test hook
+ qa.root?.let { java.io.File(it, "candidate-${qa.captures}.json").writeBytes(data) } // test hook
+ qa.gate("upload-${qa.captures}") // test hook
vssBackupClient.putObject(key = BackupCategory.WALLET.name, data = data).getOrThrow()
+ qa.uploads++ // test hook
+ qa.root?.let { java.io.File(it, "uploaded-${qa.uploads}.json").writeBytes(data) } // test hook
+ qa.log("uploaded=${qa.uploads} capture=${qa.captures} bytes=${data.size}") // test hook
}
}.map { }
}
@@ -705,6 +779,8 @@ class BackupRepo @Inject constructor(
"Wallet backup restore is incomplete"
}
lateinit var transfers: List<TransferEntity>
+ to.bitkit.utils.SnapshotQa.captures++ // test hook
+ to.bitkit.utils.SnapshotQa.log("capture=${to.bitkit.utils.SnapshotQa.captures} start generation=$sdkGeneration") // test hook
val privateRepo = privatePaykitRepo.get()
val privateVersion = privateRepo.backupStateVersion.value
val paykitSdkBackupState = privateRepo.backupSnapshot().getOrThrow()
@@ -754,6 +830,8 @@ class BackupRepo @Inject constructor(
),
)
+ to.bitkit.utils.SnapshotQa.gate("capture-${to.bitkit.utils.SnapshotQa.captures}") // test hook
+ to.bitkit.utils.SnapshotQa.log("capture=${to.bitkit.utils.SnapshotQa.captures} final private=$privateVersion/${privateRepo.backupStateVersion.value} sdk=${paykitSdkBackupState.sdkVersion}/${paykitSdkService.backupStateVersion.value}") // test hook
check(paykitSdkService.runtimeGeneration == sdkGeneration) {
"Paykit identity changed during wallet snapshot"
}
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index 0b4759a49..becdef091 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -697,6 +697,13 @@ class PrivatePaykitRepo @Inject constructor(
}
}
+ internal suspend fun snapshotQaConsume(version: ULong) = withContext(serializedDispatcher) { // test hook
+ val key = requireNotNull(pubkyService.currentPublicKey()) // test hook
+ ensureState().contacts.getOrPut(key) { ContactState() }.consumedPrivatePaymentListVersion = version // test hook
+ persistState(markWalletBackup = true) // test hook
+ to.bitkit.utils.SnapshotQa.log("private version=$version revision=${backupStateVersion.value}") // test hook
+ } // test hook
+
internal suspend fun backupSnapshot(): Result<PrivatePaykitBackupSnapshot> =
withContext(serializedDispatcher) {
runSuspendCatching {
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index cca86961c..a50d319b9 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -1307,6 +1307,7 @@ class PaykitSdkService @Inject constructor(
check(runtimeGeneration == generation) { "Paykit runtime changed before backup export" }
if (priority == Priority.Background && isPaymentSubmissionActive.value) return@withLock null
val state = withPaykitKey { completeSdkCall { it.exportBackupString() } }
+ to.bitkit.utils.SnapshotQa.log("export version=${backupStateVersion.value} bytes=${state.length}") // test hook
PaykitSdkBackupExport(state, backupStateVersion.value)
}
} while (backup == null)
@@ -1314,6 +1315,18 @@ class PaykitSdkService @Inject constructor(
}
}
+ internal suspend fun snapshotQaMutate(label: String) { // test hook
+ isSetup.await() // test hook
+ operationLock.withLock { // test hook
+ to.bitkit.utils.SnapshotQa.gate("mutation") // test hook
+ withStateRevisionTracking { handle -> // test hook
+ val key = pubkyPublicKeyFromSecret(PubkyLocalSecretKey(ByteArray(32) { 7 })) // test hook
+ completeSdkCall { handle.saveContactsAndUnblockPeers(listOf(ContactUpdate(key, label))) } // test hook
+ } // test hook
+ to.bitkit.utils.SnapshotQa.log("mutation label=$label version=${backupStateVersion.value}") // test hook
+ } // test hook
+ } // test hook
+
suspend fun retainRecoveryBackup(backup: String) {
keychain.upsertString(Keychain.Key.PAYKIT_RECOVERY_BACKUP.name, backup)
}
diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt
index 026af2984..04d3201b1 100644
--- a/app/src/main/java/to/bitkit/ui/MainActivity.kt
+++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt
@@ -94,6 +94,9 @@ class MainActivity : FragmentActivity() {
@Inject
lateinit var hwWalletRepo: HwWalletRepo
+ @Inject lateinit var snapshotBackupRepo: to.bitkit.repositories.BackupRepo // test hook
+ @Inject lateinit var snapshotSdk: to.bitkit.services.PaykitSdkService // test hook
+
private val appViewModel by viewModels<AppViewModel>()
private val walletViewModel by viewModels<WalletViewModel>()
private val blocktankViewModel by viewModels<BlocktankViewModel>()
@@ -106,6 +109,7 @@ class MainActivity : FragmentActivity() {
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ if (to.bitkit.BuildConfig.DEBUG) to.bitkit.utils.SnapshotQa.root = java.io.File(filesDir, "snapshot-qa").apply { mkdirs() } // test hook
initNotificationChannel()
initNotificationChannel(
@@ -289,6 +293,16 @@ class MainActivity : FragmentActivity() {
}
private fun handleLaunchIntent(intent: Intent) {
+ if (to.bitkit.BuildConfig.DEBUG && intent.hasExtra("snapshot-qa")) { // test hook
+ val command = intent.getStringExtra("snapshot-qa").orEmpty() // test hook
+ if (command.startsWith("mutate:")) { // test hook
+ kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { // test hook
+ to.bitkit.ext.runSuspendCatching { snapshotSdk.snapshotQaMutate(command.substringAfter(':')) }.onFailure { android.util.Log.e("SnapshotQa", "mutation failure", it) } // test hook
+ } // test hook
+ } else snapshotBackupRepo.snapshotQa(command) // test hook
+ intent.removeExtra("snapshot-qa") // test hook
+ return // test hook
+ } // test hook
if (intent.getBooleanExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE, false)) {
intent.removeExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE)
appViewModel.onPaykitSubscriptionNotificationTapped(
diff --git a/app/src/main/java/to/bitkit/utils/SnapshotQa.kt b/app/src/main/java/to/bitkit/utils/SnapshotQa.kt
new file mode 100644
index 000000000..43c71c074
--- /dev/null
+++ b/app/src/main/java/to/bitkit/utils/SnapshotQa.kt
@@ -0,0 +1,21 @@
+package to.bitkit.utils
+
+import android.util.Log
+import java.io.File
+import kotlinx.coroutines.delay
+import to.bitkit.BuildConfig
+
+internal object SnapshotQa { // test hook
+ var root: File? = null // test hook
+ var job: kotlinx.coroutines.Job? = null // test hook
+ var captures = 0 // test hook
+ var uploads = 0 // test hook
+ fun log(value: String) { if (BuildConfig.DEBUG) Log.i("SnapshotQa", value) } // test hook
+ suspend fun gate(name: String) { // test hook
+ if (!BuildConfig.DEBUG) return // test hook
+ val dir = root ?: return // test hook
+ log("gate $name") // test hook
+ while (File(dir, "hold-${name.substringBefore('-')}").exists() && !File(dir, "release-$name").exists()) delay(100) // test hook
+ log("release $name") // test hook
+ } // test hook
+} // test hookNote
Checked the manual test boxes for 1, 3 and 4; test 2 carries its earlier pass.
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full review of the complete PR diff against its merge base, at f1d1679.
No new actionable code findings.
Wallet backup capture pairs the Paykit SDK export with the backup version read under the existing operation lock, so a mutation already included in that export no longer fails the consistency check. Private, SDK, and reservation versions are rechecked after the existing proof and signed-attempt capture. A mismatch recaptures up to three times, then leaves the backup required without the hard-failure latch, so the status observer can retry after the 5s debounce once activity is quiet. An identity generation change still fails the attempt and does not upload. Restore and unsigned-attempt guards are unchanged.
Compared with bitkit-ios#950 at 0387da32 for this backup contract only. iOS also uses a wallet-wide change counter and revalidates the SDK version under the operation lock; Android rechecks private, SDK, reservation, and runtime generation. iOS's all-category pending retry matches Android's existing required/synced observer, which this PR does not change.
Validation: this review did not run tests or a device. The CI build on this revision runs testDevDebugUnitTest and passed; lint passed. The new backup, export, and private-snapshot unit tests were inspected. The emulator report is an external Android 16 pass of the fault-injection checklist on this revision with uncommitted gates, not a result from this review. Identity replacement was not part of that device run. The six-minute mainnet gap in #1487 was not reproduced. Sustained mutations can still defer a consistent capture.
Device testing: not performed in this review.
Fixes #1487
This PR keeps wallet backups progressing during Paykit activity without accepting inconsistent snapshots.
Description
Out of Scope
Design
N/A - no UI changes.
Preview
N/A - no UI changes.
QA Notes
Journeys
N/A - not drivable; see Manual Tests.
Manual Tests
wallet-snapshot-consistency.mdfor the shared platform checks.Automated Checks
BackupRepoTest.kt- covers fresh recapture, queued SDK mutations, final SDK/reservation changes, identity replacement during and between attempts, and automatic retry with and without a newer required revision.PaykitSdkServiceTest.kt- verifies the exported state and version are paired after queued mutations and remain fenced from later runtime changes.PrivatePaykitRepoTest.kt- preserves private-state export and restore coverage with the versioned internal snapshot.Local validation: compile, all 4,247 tests (239 suites, zero failures/errors/skips), and dev APK build passed. The complete Detekt report contains the same 1,401 findings as the exact master-tree baseline, including formatting; no added findings or count increases. Full-compilation warnings matched the baseline, and final affected recompilation introduced none. Independent final correctness/native/polish review found no actionable issues. The original controlled snapshot-collision regression failed before the fix. Current-head build, lint, Detekt, all seven E2E shards and required e2e-status passed.
Reviewer report 5540822791 exercised manual cases 1-4 on
f1d16797in an Android 16 emulator using uncommitted capture/upload gates. Case 2 rejected three changing captures without uploading, then automatically uploaded the quiet state without another mutation. Case 3 establishes held-upload follow-up and cancellation, not an actual identity-replacement device run. Case 4 compared restoration with the retained uploaded envelope, without broadcasting. These are reviewer-reported controlled results, not independent physical-device or historical-mainnet reproduction. Identity replacement remains covered by native tests only.The matching iOS fix is synonymdev/bitkit-ios#950.