Repository navigation
fix: decouple wallet restore from profile refresh - #1686
ben-kaufman wants to merge 22 commits into
Conversation
|
Triage: 3.1.0. It keeps the shipped wallet restore from waiting on profile refresh, and 3.1.0 is due 28 Oct. |
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryWallet restore now schedules optional identity activation and profile reads without waiting for them.
No actionable issues found. Review covered source and tests; tests were not run. Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Required wallet and USDT recovery] --> B{Succeeded?}
B -->|No| C[Keep restore blocked]
B -->|Yes| D[Schedule identity work]
D --> E[Complete wallet restore]
D --> F{Identity revision still current?}
F -->|No| G[Skip obsolete work]
F -->|Yes| H[Activate identity and load profile]
I[Build backup] --> J{Identity data complete?}
J -->|No| K[Keep previous remote backup]
J -->|Yes| L[Upload backup]
Reviews (1) · Last reviewed commit: "fix: decouple wallet restore from profil..." · Reviewed by Greptile |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Moves Pubky identity activation after a wallet restore off the restore screen's critical path (scheduleInitializationAfterRestore, coalesced per stored-identity revision), and makes WALLET and METADATA backups fail rather than upload a payload with the identity part silently missing. Reviewed ac2a7c6, full tier, stacked on #1685 (fix/pending-wallet-restore-admission), reviewed as the delta against that base.
What I checked, and 6 candidates I ruled out
Read in full: PubkyRepo.kt (initialization, restore, adoption, sign-out and backup-state regions), BackupRepo.kt (scheduling, triggerBackup, markBackupFailed, wallet and metadata snapshots, full restore), WalletViewModel.kt restore paths, PrivatePaykitRepo.backupSnapshot, PaykitSdkService (currentPublicKey, session provider, clearSessionAccess), and paykit-rs v0.1.1-rc1 identity_status / initialize.
Call sites traced: scheduleInitializationAfterRestore (both WalletViewModel restore paths), hasIdentityForBackup (only captureWalletBackupDataBytes), initialize() (still only the init block, unchanged semantics with expectedRevision = null).
CI: lint, detekt and build-local green. build is red on one test, PubkyAuthManifestTest > signup and authorization links resolve only through their enabled aliases, at its first assertion (line 77, every alias still at its default). The diff touches neither that test nor the manifest; the base head 837aa1c passed build, and the same test failed the same way in two of the last eight failed ci runs on unrelated branches (fix/update-terms-of-use, feat/usdt-design-followup-20261015). All seven new tests in PubkyRepoTest, BackupRepoTest passed in that run. The e2e shards were still running when I read the checks.
Not built here: this review is reasoned from the code and CI; CI is the gate.
Ruled out
- Scheduled activation lost when the revision moves: the revision only moves through
startSignIn(non-restoring) or a non-preservingclearAuthenticatedState, both of which are a newer identity's own path, so dropping the stale job is the intent.restoreSessionBackupStatebumps it inside the restore, before the schedule reads it. - Something after
Completedneeding the identity: the public key is already set inside the required boundary (restoreSessionBackupStateLocalSeedstartSignIn), and the payment-requestactivateatBackupRepo.kt:1058runs during the restore, unchanged. - Coalescing reusing a finished job:
takeIf { !it.second.isCompleted }, and the completion handler clears the slot only when it still holds the same job. - Uncaught failure in the background job:
initializeSessioncatches its own failures,checkAdoptedSourcereturns aResult, andappScopecarries aSupervisorJobplus a logging handler. - WALLET backup blocked for good while a saved session exists: with a stored session
currentPublicKey()reads the bound identity state throughidentity_status, sostateis null only when the SDK has no identity, which is the case the check means to hold; an unreadable keychain fails the backup (covered bywallet backup cannot treat unreadable identity as absent). - METADATA
getOrThrowturning a benign miss into a failure: both snapshots returnnullfor absence insiderunSuspendCatching, so only a read error now fails, which is what the two new tests pin.
Merge confidence: 3/5, the build job is red on PubkyAuthManifestTest, which I traced to a flake outside this diff; a green re-run would move it to 4/5 (J1 is still unrun).
Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: one LOW inline, reproduced with a failing unit test.
Checked and clean:
- No cross-identity leak on wipe and restore: the mutex, the revision and the
isCurrent(signIn)guards hold. - Identity isn't lost for good: the retry paths cover a failed background refresh. The WALLET backup that can stay blocked is the open P1 thread.
- No payment path starts before the identity exists: the key is set inside the required restore.
- Twin synonymdev/bitkit-ios#1121 keeps the header signed-out until the background activation lands, so it has no flip.
Performance-relevant (the purpose is a faster restore). wallet/restore-during-identity-outage.xml needs fault controls that aren't provisioned, so the gate uses a proxy. It restores a regtest wallet whose backup carries a pubky session, then:
- times
Full restore success→ Completed, head vs master, with the homeserver reachable and with it blocked; - checks that the header shows the identity and that profile and contacts arrive afterwards;
- counts
Restored paykit session forper restore (expected 1); - runs Settings → Backup for WALLET once the identity is active.
Device gate (partial): ac2a7c6 — not run yet; pending that proxy run. Device results will follow in a separate review.
There was a problem hiding this comment.
Verdict:
Findings: No new findings. I confirmed that the redundant session import is fixed at 0fd5e42. The acknowledged terminal-restoration backup limitation still applies to Android: retained credentials with no SDK identity can block later complete WALLET backups. Temporary recovery below does not resolve that case.
QA for 0fd5e42: Android 16, API 36 emulator, staging regtest, an unfunded disposable wallet. I built the unmodified head and ran its focused native suites, then drove the app with local Debug controls over the real SDK and VSS paths. Installed APK bytes matched the built artifacts.
Test 1: J1 — complete optional identity outage journey ⏭️ Partial: activation first occurring after required recovery was not driven.
This LocalSeed backup activates its identity during required metadata restoration. Its later scheduled work enriches an already-active identity, so the first action's unauthenticated-completion expectation was not established. The subcases below cover held enrichment, unavailable SDK export, restart, stale profile rejection and required-recovery failure. Initialization with authentication unavailable was held separately across process restart.
Genuine post-required activation failure and funded payment replay remain unverified.
Test 2: J1 — restore with optional enrichment held ✅ Restore completed and ordinary wallet access remained available before release.
The success screen appeared while final enrichment was held. I left restore and opened Receive. App logs confirmed a running node, clear required-recovery markers and successful new-address allocation. Android kept the identity activated by required metadata restoration; the profile was still empty until enrichment resumed.
J1-restore2x.2x.mp4
Required restore, wallet access and receiving while enrichment is held; 2×. Node, marker and allocation state were checked in app logs.
Test 3: J1 — complete-backup protection and recovery ✅ An unavailable SDK identity preserved the remote WALLET record, then recovery permitted a complete backup.
The control removed live SDK session access, retaining the original identity secret for recovery. The real WALLET backup failed with Paykit identity is unavailable for backup; remote bytes and version stayed unchanged and backup work remained pending. Real sign-in with the original secret then allowed a fresh SDK export and complete upload, with pending status cleared. These assertions were checked in the control and app logs.
J1-backup-final2x.2x.mp4
Rejected backup, identity recovery and resumed profile display; 2×. Backup contents and pending status were checked in app logs.
Test 4: J1 — process restart during held work ✅ Saved credentials recovered the same identity and a single saved contact.
I killed the app during held optional work and held initialization again after relaunch. Authentication stayed unavailable while the ordinary wallet ran. Releasing initialization restored the identity and the same one contact. The contacts screens and app logs confirmed the result; no payment was submitted.
J1-restart2x.2x.mp4
One contact before and after process restart and initialization release; 2×. Authentication and node state were checked in app logs.
Test 5: J1 — delayed profile response after identity removal ✅ The late response did not restore the removed identity or profile.
I held a real profile response, removed the identity through the normal repository sign-out, then released the response. Its normal guard reported current=false; authentication and profile remained empty. The UI and app logs confirmed that the old profile was not applied.
J1-stale2x.2x.mp4
Identity removal while a profile response is held, then release; 2×. Stale-response rejection was checked in app logs.
Test 6: J1 — required recovery failure and retry ✅ Required markers blocked address and payment admission until the original payload was repaired and restored.
A malformed retained WALLET payload kept the restore-error screen with no Proceed action. Address allocation returned WalletBackupRestorePendingError. An invoice-admission probe returned the same error before invoice parsing or dispatch. After repairing the original retained WALLET and USDT-recovery payload copies, Retry completed required recovery while optional enrichment was still held. Markers cleared and address allocation succeeded. These states were checked in app logs.
J1-required-final2x.2x.mp4
Required-recovery error, repair, retry and success with enrichment held; 2×. Operation admission was checked in app logs.
Test 7: base comparison — held optional initialization ✅ The base waited for release; the reviewed head completed with the hold active.
I built and drove 55b31ee1 on the same emulator with the same disposable backup and a hold at the corresponding optional-initialization boundary. Required restore logged success, but the base remained at 100% throughout the 34.6-second hold. It completed after release. Test 2 shows the reviewed head completing before release. This is a controlled comparison, not a live homeserver-blackout benchmark.
C2-base2x.2x.mp4
Pinned base waiting after required recovery, then completing after release; 2×. The hold interval was measured from app logs.
Check 1: focused native regressions ✅ The unmodified-head build and selected suites passed without failures or skips.
./gradlew assembleDevDebug testDevDebugUnitTest \
--tests to.bitkit.repositories.PubkyRepoTest \
--tests to.bitkit.repositories.BackupRepoTest \
--tests to.bitkit.ui.WalletViewModelTestI inspected the test XML and complete build log. The suites cover coalescing, active-session preservation, replacement identities, unreadable credentials, complete-backup protection and required-recovery admission.
Local Debug controls and deciding log lines
Not pushed. Holds wait for explicit file removal. The backup control clears this disposable wallet's SDK session access and recovers using its original secret. The required-recovery control corrupts retained local payload input and repairs it from the captured original before Retry. SDK results and recovery decisions are not mocked.
Full restore success
QA1686 final enrichment held
identity=true, profile=null, contacts=1, pendingRestore=false, node=Running
QA1686 unavailable identity: failed=true, unchanged=true, pending=true
QA1686 qa1686BackupFault: backup protection and recovered export passed
QA1686 profile response held
QA1686 qa1686SignOut: identity removed
QA1686 profile released; current=false
QA1686 qa1686SpendingGate: payment admission blocked by required restore before invoice parsing
QA1686 qa1686RepairRequired: repaired original required backup
Full restore success
QA1686 final enrichment held
Head controls:
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..7aaf9c1ac 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -42,6 +42,11 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) { // test hook
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { // test hook
+ Qa1686.run(requireNotNull(context), method) // test hook
+ } // test hook
+ } // test hook
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 41854bbf9..9b0179f2f 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -375,6 +375,12 @@ class PubkyRepo @Inject constructor(
private suspend fun initializeForRevision(
expectedRevision: Long? = null,
): Result<Unit> = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/" + if (expectedRevision == null) "qa1686-hold-initial" else "qa1686-hold-final") // test hook
+ if (gate.exists()) Logger.info("QA1686 final enrichment held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 final enrichment released", context = "TestHook") // test hook
+ } // test hook
val (restored, revision) = initializeMutex.withLock {
if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) {
return@withContext Result.success(Unit)
@@ -842,6 +848,12 @@ class PubkyRepo @Inject constructor(
?: throw AppError("Profile not found")
}
}.onSuccess { loadedProfile ->
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-profile") // test hook
+ if (gate.exists()) Logger.info("QA1686 profile response held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 profile released; current=${isCurrentLoad()}", context = "TestHook") // test hook
+ } // test hook
var isCurrent = false
_profile.update {
isCurrent = isCurrentLoad()
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..f52d01a3f
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,125 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+
+object Qa1686 { // test hook
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "qa1686Capture" -> { // test hook
+ File(context.filesDir, "qa1686-mnemonic").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ for (c in listOf(BackupCategory.METADATA, BackupCategory.SETTINGS, BackupCategory.WALLET)) d.backupRepo().triggerBackup(c).getOrThrow() // test hook
+ check(d.vss().getObject("WALLET").getOrThrow() != null) // test hook
+ "captured complete backup and test credentials" // test hook
+ } // test hook
+ "qa1686Clipboard" -> { // test hook
+ withContext(Dispatchers.Main) { // test hook
+ context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) // test hook
+ } // test hook
+ "copied test credentials" // test hook
+ } // test hook
+ "qa1686BackupFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) // test hook
+ d.pubkyService().forgetSessionAccess() // test hook
+ check(d.pubkyService().currentPublicKey() == null) // test hook
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET) // test hook
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true // test hook
+ val same = before.value.contentEquals(after.value) && before.version == after.version // test hook
+ Logger.info("QA1686 unavailable identity: failed=${failed.isFailure}, unchanged=$same, pending=$pending", context="TestHook") // test hook
+ check(failed.isFailure && same && pending) // test hook
+ d.pubkyService().signIn(secret) // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ val fresh = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val complete = json.decodeFromString<WalletBackupV1>(fresh.value.decodeToString()).paykitSdkBackupState != null // test hook
+ val synced = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == false // test hook
+ check(complete && synced) // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "backup protection and recovered export passed" // test hook
+ } // test hook
+ "qa1686Recover" -> { // test hook
+ val secret = d.pubkyService().deriveSecretKey(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secret) // test hook
+ d.pubkyService().initialize() // test hook
+ d.pubkyRepo().restoreSessionBackupState(to.bitkit.models.PubkySessionBackupV1(kind = to.bitkit.models.PubkySessionBackupKind.LocalSeed)).getOrThrow() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "recovered identity and fresh complete backup" // test hook
+ } // test hook
+ "qa1686Refresh" -> { // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "scheduled optional enrichment" // test hook
+ } // test hook
+ "qa1686RequiredFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value) // test hook
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, "invalid-json") // test hook
+ "staged malformed retained required backup" // test hook
+ } // test hook
+ "qa1686RepairRequired" -> { // test hook
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ "qa1686SignOut" -> { // test hook
+ d.pubkyRepo().signOut().getOrThrow() // test hook
+ "identity removed" // test hook
+ } // test hook
+ else -> "identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hookBase-comparison controls:
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..30160f67f 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -42,6 +42,9 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) { // test hook
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { Qa1686.run(requireNotNull(context), method) } // test hook
+ } // test hook
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 39a03aefb..17e79f4e1 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -351,6 +351,12 @@ class PubkyRepo @Inject constructor(
}
suspend fun initialize() = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG && keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-final") // test hook
+ if (gate.exists()) Logger.info("QA1686 base optional initialization held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 base optional initialization released", context = "TestHook") // test hook
+ } // test hook
val restored = initializeMutex.withLock { initializeSession() }
if (restored) loadIdentityData()
checkAdoptedSource()
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..e40475dbe
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,27 @@
+package to.bitkit.dev // test hook
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import java.io.File // test hook
+object Qa1686 { // test hook
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { fun pubky(): PubkyRepo; fun backup(): BackupRepo; fun lightning(): LightningRepo } // test hook
+ suspend fun run(context: Context, method: String): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val text = if (method == "qa1686Clipboard") { // test hook
+ withContext(Dispatchers.Main) { context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) } // test hook
+ "copied test credentials" // test hook
+ } else "identity=${d.pubky().publicKey.value != null}, profile=${d.pubky().profile.value?.name}, contacts=${d.pubky().contacts.value.size}, pendingRestore=${d.backup().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ return Bundle().apply { putString("result", text) } // test hook
+ } // test hook
+} // test hookCoverage: full 55b31ee1..0fd5e42, all nine changed files and reached restore, identity replacement, backup and SDK paths. Earlier connection-editor steps, a USDT-specific restore failure, terminal identity failure on device, a live homeserver blackout and funded payment replay were not driven.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh matches the optional scheduling and complete-backup protections. Android preserves an identity already activated during required metadata restoration.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
jvsena42
left a comment
There was a problem hiding this comment.
Code scan of d4ba907: no findings. PubkyRepo.kt:382-386 now skips the import when _publicKey is already set for the stored revision, so a scheduled restore keeps the active identity. The WalletViewModelTest.kt conflict resolution and the journey edit are sound.
Expected for the proxy run: no Restored paykit session for after Full restore success for a LocalSeed backup (was 1), one for an ExternalSession backup.
Device gate (partial): 0fd5e42 — not run yet; still pending the identity-outage proxy run. Device results will follow in a separate review.
|
Device gate (partial):
Still to settle before the final line: after the outage, head didn't reload the own profile. The header showed "Your Name" and the own Contacts row only the key from 10:41:33 to 10:44:44 (
|
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: full review of the PR diff against its merge base 55b31ee, at 0fd5e42. No saved baseline existed, so nothing was carried over. I also compared the restore scheduler and the WALLET backup guard against the iOS twin #1121 at 39fcaec, and checked composition with #1685 at 7b823d2. Neither of those is a full review of the companion PR.
No new actionable code findings.
- Restore decoupling: the restore now reaches
Completedwithout waiting for optional identity setup. That setup is only scheduled afterperformFullRestoreFromLatestBackupsucceeds, on both the VSS path and the RN path, so required WALLET/USDT recovery still comes first. Duplicate requests for the same identity revision are merged into one. Revision checks before and after profile/contact loading drop work for an identity that has since been replaced or removed. The job runs in the supervisor-backed repository scope, so a failure in background setup is logged and cannot crash the app or turn a completed restore into a failed one. The iOS twin uses the same model. - Earlier redundant-import thread: this thread is fixed at this head. Scheduled setup now skips
initializeSession()when the restore has already activated the identity (PubkyRepo.kt:382-386), and the newPubkyRepoTestregression covers that case. I checked this by reading the source and the test, not by running it. - Existing open concern (not counted above): after a terminal Paykit restoration failure, saved credentials stay in place and the new guard keeps failing every WALLET backup. That WALLET backup also holds transfers, reservation indices, watch-only accounts and payment-proof state. This is the mechanism already tracked in the iOS #1121 thread, and the author confirmed there that it applies to Android. On Android the guard throws a non-temporary
IllegalStateException, so there is no timed retry. It is only retried when a later change re-marks the WALLET backup as required, and the periodic backup-failure notification applies. - Dependency composition: #1685 has moved to
7b823d2, past this PR's base. A local trial merge of this head with that revision merged without conflicts and adds no new restore-timepubkyRepo.initialize()callers. I did not compile or test the merged tree.
Validation: code-only review. I did not run any Gradle tasks, unit tests or E2E. The author reports green CI, lint and E2E at 0fd5e42. Those runs do not cover J1, whose fault controls are not provisioned.
Device testing: not performed in this review.
Recommended before device testing: settle the terminal-restoration WALLET backup behaviour in the linked iOS thread, since it applies to both platforms.
Suggested additional test cases
- Android, wallet with no Pubky profile, so the backup's
pubkySessionis empty: restore from VSS, then make a wallet change such as receiving on-chain. Expected: Settings → Backup shows the WALLET backup succeeding, and the new identity guard does not block a wallet that has no identity. - Android, wallet with an active Pubky profile: sign out of the profile, then make a wallet change. Expected: the WALLET and METADATA backups both succeed afterwards, with no lingering backup-failure notification, which confirms sign-out clears every credential the guard checks.
Reviewer model: claude-opus-5-5
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Approving with one LOW open, inline at PubkyRepo.kt:382: the own profile isn't reloaded after an outage during restore. Fix it here or move it to a follow-up issue, your choice; reply on the thread saying which.
Device gate: 0fd5e42, Gate_Throwaway emulator through a local homeserver fault proxy, base = merge-base 55b31ee. LocalSeed backup: a profile created in Bitkit on staging, with two contacts.
- perf:
Full restore success→ Completed took 2.0–2.2 s on head with the homeserver reachable, held or blackholed. Base took 12.75 s reachable, 103.65 s held, and 192–259 s blackholed (it waits on re-sign-in:Failed to restore paykit session, attempting re-sign-in). - Head keeps the identity through the outage and lists the contacts. Base drops the identity and shows the Contacts intro until the outage ends.
- The LOW above reproduced in 2 of 2 runs.
- After release, the WALLET backup on head took about 95 s, through repeated
Retrying backup for: 'WALLET' … acquire Pubky write lock. Base took about 23 s with one retry. This is the same pubky-sdk write-lock window seen on synonymdev/bitkit-ios#1124, which is not Bitkit code, so I haven't raised it here.
restore-during-identity-outage.xml steps 2 and 6. With a LocalSeed backup the identity activates inside the metadata restore, before a fault can start, and VSS shares a staging host the proxy can't isolate. Step 4 was timed only through the restore measurement above. An ExternalSession backup wasn't tested.
CI is green at 0fd5e42.
|
Device check: a native crash on backgrounding with VSS unreachable. Not from this PR: master crashes the same way. The crash is a SIGABRT in Setup: Pixel_9 emulator, a fresh wallet, the VSS host blocked by DNS with Electrum up, the cached-apps freezer disabled, and a SETTINGS backup pending (denomination change) before Home.
Master's crash has the same abort message and the same 39-frame backtrace; only one frame offset differs: So it is an existing ldk-node shutdown race, not something the bitkit-core or vss-client bumps in this PR introduce. My approval stands. The crash needs its own issue against ldk-node. |
|
Thanks; this matches the already-open native shutdown tracker #1536. I linked your exact head/master comparison there, preserving the VSS/SETTINGS prerequisites, identical-library claim and small-sample limits. The new data remains attributed external evidence until the raw symbols/tombstones are inspected. No duplicate ldk-node issue or app-scope shutdown rewrite is being introduced, and no existing CI failure is waived. |
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the entire pinned PR at 89e2702e against merge base c747cc0b, after the comparison base changed. No coverage was inherited.
No new actionable code findings.
The previously reported HIGH terminal-identity backup concern and LOW profile-refresh retry concern remain unresolved at this head. Temporary recovery does not resolve terminal backup availability. Existing execution reports retain their original revisions.
Targeted comparisons with iOS c0b1e86f and dependency Android #1685 at c747cc0b checked optional initialization, authentication presentation, required-recovery admission and backup protection; neither received a full companion review.
Validation: matching-head CI and lint succeeded. Their synthetic merge tree equals the pinned head; the CI log records all 4,607 tests passing, including the changed regressions. Local diff-whitespace and journey XML checks passed. Focused Gradle tests stopped before execution because the Android SDK is unavailable. Current-head E2E is still in progress.
Device testing: not performed in this review. J1 remains unexecuted here and its complete-app fault controls are not committed. Resolve the existing concerns and provision those controls before treating the change as ready for a complete device pass.
Suggested additional test cases:
- Android: restore a wallet with no Pubky identity, make an ordinary wallet change, and verify WALLET and METADATA backups synchronize.
- Android: disconnect an adopted Ring identity, make an ordinary wallet change, and verify retained source credentials do not prevent subsequent backup synchronization.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Code scan of the delta 78269d8..b6751e2: one LOW inline (journey text). Nothing above LOW.
Checked and clean:
- 1b1426f carries the same stale-activation guard as #1684 (revision re-checked under
activationLockaftercanActivate()). - 181d664 adds
allowancePauseRevision. It only bumps when the setting turns off, andisCurrentPaykitContactSyncrequires it to match, so an off/on during admission re-runs activation from the latest state. Nothing re-enables auto-pay without the setting reading true in the snapshot, in the live re-read and insidecanActivate. - b6751e2 is test-only.
- My LOW (a) is fixed:
PubkyRepo.kt:401-407now skips the reconnect, resume and maintenance profile retry whileisPubkyProfileSetupPending, andidentity awaiting profile setup does not re-read its missing profile on every recoverycovers it. - My LOW (b) is fixed:
PaykitSdkService.kt:1328.
perf: one extra contact sync per cold start when allowances are off (the off-collector bumps the revision once). The first run normally takes the early return, while the public key is still null. I'll check the count in the log on device.
Device gate (partial): b6751e2 — not run yet; pending: allowances/allowances-turned-off.xml on two Android devices with an Active allowance, and the contact-sync count per cold start with allowances off. The device result will follow.
jvsena42
left a comment
There was a problem hiding this comment.
Approving at b6751e2. Device gate on two emulators (payer and payee with a live allowance over regtest channels) found nothing above LOW. The journey LOW from my earlier review stays open.
allowances-turned-off.xml: all 7 actions pass.
- With the setting off, Subscriptions shows only Overview and Payments.
- A one-time request from the payee opened the ordinary Payment Request sheet. No allowance decision was logged and nothing was paid.
- Dismiss removed the request from the bell list.
- Turning it back on restored the Allowances tab with the row Active, and the next request was paid by the allowance.
- Deviation: amount entered in sats, as
allowances/README.mdsays for Android.
Off/on during delayed contact loading: passed in two variants, with no relaunch afterwards.
- App running, airplane mode on, toggle off/on/off/on, airplane mode off. The row came back Active and the next request was paid by the allowance.
- Cold start offline, so contacts and the Pubky session never loaded, then the same toggles. After going online the row was Active and the next request was paid by the allowance.
⚠️ Offline, the Pubky calls fail at once instead of hanging, so neither run is guaranteed to land the toggles while an activation is still in flight. The unit test remains the direct proof of that window.
Contact-sync work per cold start with allowances off (head vs master, 3 runs each, 180 s): no extra work on head.
- No log line marks a contact sync, so I counted proxies instead:
identityStatusoperations 7/7/7 on head vs 6/10/6 on master; "Prepared Paykit key" 52/51/51 vs 48/64/45. - The variance follows how long the shared-state lock takes to free after a force-stop, on both builds.
Not run: the PIN-gate and notification-off variants.
|
@ben-kaufman this conflicts with |
|
Addressing the master integration request: Resolved the live-master conflict with signed additive merge 43e0f3f, preserving the published history. Both restore routes retain deferred identity initialization and forward master’s optional-category warnings. Final format/compile/full unit tests/lint passed: 4,873 tests, zero failures/skips, zero static findings. The complete diagnostics, test-fixture failure/repair and remaining runtime gaps are disclosed in the updated description. All 18 current-base range commits are locally and GitHub verified. New-head hosted checks, including Compose UI, are now running. |
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving after the rebase. The delta since b6751e2 adapts restore to master's failed-category result (RestoreState.Completed(failedCategories)) and keeps scheduleInitializationAfterRestore() on both restore paths; the new tests cover failed categories surviving a failed identity activation. CI is green.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The existing terminal WALLET backup finding is still open. The master merge itself is clean, and every case I drove passed.
Findings: No new findings in this delta. One existing HIGH remains open.
- Existing HIGH: Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:817. This merge leaves the PR'sBackupRepo.ktpatch identical, and the PR description still lists the finding as open.
The merge keeps scheduleInitializationAfterRestore() on both restore routes (WalletViewModel.kt:490 and :533). It forwards master's failed categories into RestoreState.Completed and still throws before scheduling when a restore fails. Master's METADATA completeness reporting, its allowance executor and store changes, and its ledger-floor handling sit alongside the PR's deferred identity, activation tokens and complete-backup guards without conflict. The two test-fixture edits are type changes plus a stub for master's new isLedgerBehind call.
QA for 43e0f3f: Android 15 emulators, staging regtest and VSS for the restore cases. The allowance journey ran on the local regtest and Pubky testnet backend with an E2E build of this head, because automatic LSP channels are no longer available on staging. Videos play at 2×.
Restore a backed-up wallet that has a Pubky profile ✅ Plain "Wallet restored" text, then Home showed the saved profile name without opening Profile.
I backed up a wallet with a profile, cleared the app and restored it from the recovery phrase. The success screen showed the normal text with no incomplete-categories warning. Home then showed Restore1686 and a receive address. After a force-stop and relaunch, the profile showed the same pubky. The log shows METADATA, SETTINGS, WIDGETS, WALLET and ACTIVITY restored, then Full restore success.
| Restore | Relaunch |
|---|---|
restore-with-profile2x.2x.mp4 |
restore-with-profile-relaunch2x.2x.mp4 |
Restore while the Pubky service is unreachable ✅ The wallet restored and was usable, and the profile came back by itself once Pubky was reachable.
I blocked the homeserver and pkarr relays on the emulator, leaving VSS and Electrum reachable. The restore finished with "Your wallet and balance are back, but Bitkit could not restore: Profile, Contacts, Tags." Home opened with the "Your Name" placeholder and a receive address. About 25 s after I lifted the block, Home showed Restore1686 with the same pubky. I did nothing in the app.
The log shows METADATA backups refused while Pubky was unreachable (Public address exclusions must be backed up before replacing metadata). WALLET's first backup succeeded after the block was lifted, and METADATA followed.
The message names Profile even though the profile came back on its own. It also names Tags, although restoreMetadataBackup writes tags before the session step that failed (this wallet had no tags to show it). That wording comes from master's WalletRestoreSuccessView mapping, not from lines this PR changes, so it is not filed as a finding.
restore-during-pubky-outage2x.2x.mp4
Restart the app during the Pubky outage after restore ✅ After a relaunch while still blocked, the wallet stayed usable and the same profile returned after unblocking.
I restored with Pubky blocked, force-stopped the app and relaunched it while still blocked. Home showed the placeholder and a receive address. About 55 s after unblocking, the UI showed the same profile and key. The first clip hit the recorder's 3-minute limit just before that, so a second clip shows the recovered Home and Profile. After recovery, WALLET and METADATA backups succeeded (log).
| Outage and relaunch | After unblocking |
|---|---|
restart-during-pubky-outage2x.2x.mp4 |
restart-during-pubky-outage-after2x.2x.mp4 |
Restore a wallet that has no Pubky profile ✅ Plain success text, Home and a receive address, with no identity errors.
The log shows Full restore success and no identity errors. WALLET and METADATA backups succeeded afterwards.
restore-without-profile2x.2x.mp4
Request payment without saved contacts ✅ With a signed-in profile and no contacts, Request Payment opened Contacts with its Add Contact sheet.
request-payment-no-contacts2x.2x.mp4
Turning allowances off and back on restores the active allowance ✅ While off, the request stayed manual. Back on, the Active row returned and the next request was paid automatically.
The payer had a 500,000-sat channel and set a $5 / $50 allowance, which the payee's wallet accepted by itself. With "Enable Allowances" off, the payer's Subscriptions showed only Overview and Payments. A 2,000-sat request from the payee opened the ordinary Payment Request sheet with Swipe To Pay. It stayed unpaid, with the balance still at 500,000, and was listed under the bell before I dismissed it.
Turned back on, the Allowances tab showed "Active · $5 a payment". The next 2,000-sat request was paid automatically. The payer posted "Payment Executed: Auto-pay sent $1.55 to Payee", and the payee's node logged PaymentReceived for the same payment id.
| Off: payer left, payee right | Bell list while off | On: payer left, payee right |
|---|---|---|
allowances-off2x.2x.mp4 |
allowances-off-bell-payer2x.2x.mp4 |
allowances-on2x.2x.mp4 |
Check: terminal WALLET backup finding ⏭️ Not re-driven; the code is unchanged since the failing run at b6751e2.
Reproducing it needs an unusable saved session with no recovery secret, which the shipped app cannot produce. The BackupRepo.kt patch is identical before and after the merge, including the hasIdentityForBackup() check at line 817.
Not driven:
- Holding allowance activation, SDK admission or contact sync at a chosen moment (off/on during held activation, manual and automatic acceptance across refresh, queued offers after disable). This needs Debug timing controls the app does not have. The b6751e2 report covers them. This merge leaves the PR's allowance activation code unchanged.
- Required WALLET or USDT recovery failures and the Electrum correction. These need injected decoder failures. They were last proven in the 71fa609 report and the 78a0ff7 report. Master changed the restore code around them, so they are not re-proven at this head.
Carried: The reconnect, pending-setup and late-retry profile cases and the rejected-acceptance backup case are carried from the 1b1426f report. The sign-out and unlink backup cases are carried from the 78a0ff7 report. Neither master nor the PR changed PubkyRepo.kt or PaykitSdkService.kt in this merge, and master's BackupRepo.kt changes are on the restore side only. jvsena42's device report covers the allowance journey at b6751e2.
Review covered the b6751e2..43e0f3f merge. The PR's net patch changed only in WalletViewModel.kt and two test files, and I also read the master code it now meets.
Twin: synonymdev/bitkit-ios#1121 is unchanged at a6f233d since the last review. I did not compare whether iOS reports failed restore categories the same way.
Reviewed by claude-opus-5-5-medium
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the entire PR at 43e0f3fe against merge base 44bf50ec, after the comparison base changed from 1337f12 to 44bf50e. No coverage baseline was supplied, so nothing was inherited.
No new actionable code findings.
Required restore still finishes before identity setup is scheduled, and that setup is not awaited. An identity metadata restore already signed in is kept and only its profile and contacts are refreshed; a changed or removed identity drops the late result. Reconnect retries a missing profile unless local profile setup is still pending. Turning allowances off revokes queued admission, and turning them back on advances the contact-sync revision so the latest enabled identity is activated. A queued allowance acceptance stays tied to that activation and is refused before the SDK write if it was paused or replaced. METADATA and WALLET uploads still fail instead of storing a null identity payload.
The unresolved terminal WALLET backup thread still matches this source: a null SDK snapshot is rejected while stored identity remains, so later ordinary WALLET backups stay failed until that identity can be exported again. This review did not re-run that case. The unresolved allowances journey thread also still matches: the description adds a delayed off/on check that the journey actions do not perform. Resolved threads on redundant session import, profile retry, setup-pending reads, same-identity allowance tokens, backup admission, and off/on reactivation were rechecked against this head and their fixes still hold.
Compared with bitkit-ios#1121 at a6f233df: both schedule identity work after required restore and refuse a WALLET backup when a stored identity cannot be exported. iOS clears in-memory auth and reads persistence again; Android keeps an identity that restore already activated. The shared journey text matches.
Validation: source and unit tests were inspected, not executed here. At this SHA, unit and Compose, lint, and E2E completed successfully. Shard logs were not re-audited. Device testing: not performed in this review.
Reviewer model: grok-4.7
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at 4056d2c0 ("keep VSS operations with their original wallet"). No findings.
- Identity changes:
withSuspendedClientscloses admission and bumps the generation in one locked block. It then cancels and joins the admitted jobs beforevssShutdownClient/vssShutdownLdkClient, and the gates reopen only infinally. An in-flight put therefore finishes on the old client, and nothing reaches the next wallet's client before a fresh setup. - Node's VSS store: it belongs to ldk-node and is untouched.
- Hangs:
startNodecan't hang on a drain, becausecaptureOwner()throws while suspended. - Cancellation: the VSS calls moved to
runSuspendCatching, so a retired owner cancelsperformBackupwithout marking it synced. - iOS #1121 LOW: not reachable here, because
lifecycleMutexserialises identity changes.
CI: compose-ui / ui-tests (and the build gate that requires it) failed because the CI emulator never finished booting (ServiceNotFoundException: No service published for: input). No tests ran, so a re-run should clear it.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up of the VSS wallet-ownership delta since 43e0f3fe, at 4056d2c. Inherited baseline: review 5755240987. Merge base is unchanged at 44bf50ec.
No new actionable code findings.
Identity replacement closes both standalone VSS admissions, cancels and joins admitted backup and Lightning work, and shuts those clients down before the credential block. A failed shutdown leaves admission closed. Scheduled backups, retries, and an explicit full backup keep the captured generation. METADATA and WALLET uploads still pass through the unchanged identity snapshot checks and fail instead of storing a null identity payload. The running LDK node keeps the VSS client it built for itself.
Only the standalone-client drain was compared on bitkit-ios#1121 at 25f25ea. Both sides retire those clients before credential replacement and drop a result once the owner changes. Android cancels admitted calls and stays closed when shutdown throws. iOS waits for admitted calls to finish, and its shutdown callback does not fail the reset. VssClientCoordinatorTest covers the Android contract.
The terminal WALLET backup thread still matches the unchanged snapshot guard: a null SDK snapshot is rejected while stored identity remains, so later WALLET backups stay failed until that identity can be exported. This follow-up did not re-run that case. The allowances journey thread is marked resolved and tracked in #1766; this delta does not change that journey.
Validation: source and unit tests were inspected, not executed. At this SHA, GitHub reports unit and Compose, lint, and E2E succeeded. Compose had an earlier emulator boot failure on this SHA; the completed rerun succeeded. Device testing: not performed in this review.
Suggested additional test cases:
- Android. Disposable wallet, a backup upload held in flight, and a second recovery phrase. Replace the wallet identity before that upload returns. The in-flight upload stays on the original VSS store or is cancelled, and the replacement wallet's later backup setup uses the new phrase.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The existing terminal WALLET backup finding is still open. This commit also adds one MEDIUM: Data Backups now shows a backup as failed while it is only waiting to run. The VSS coordinator held up in every wipe, restore and reset case I drove.
Findings:
- MEDIUM: Data Backups shows a scheduled backup as failed during its 5 s debounce.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:534. The rewrittenscheduleBackupno longer setsrunning = truewhen it reserves a job. At 43e0f3f it did, beforedelay(BACKUP_DEBOUNCE)(old line 493). Nowrunningis set only inperformBackup. For the 5 s debounce, and while a WALLET backup waits for payment submission, the category hasrequired > syncedwithrunning = false.BackupSettingsScreen.kt:125-128shows that as a red "Failed Backup: now" with a Retry button. On a fresh wallet ACTIVITY is rescheduled every 5–10 s, so Transaction Log keeps flipping to "Failed Backup" while every upload succeeds. 43e0f3f shows "Running" in the same window. Settingrunning = truewhen the job is reserved, and clearing it when the job ends without a backup, restores the old status. - Existing HIGH: Terminal identity failure blocks newer ordinary WALLET backups.
BackupRepo.kt:854. This commit does not change thehasIdentityForBackup()guard.
QA for 4056d2c: Android 15 emulators, staging regtest and VSS, dev debug build of this head. Videos play at 2×.
Data Backups status while backups are scheduled ❌ This head (left) shows red "Failed Backup" rows with Retry while uploads succeed; 43e0f3f (right) shows "Running".
Both emulators had a fresh wallet with Settings > Data Backups open. On this head, Transaction Log (and Widgets or Tags at times) kept switching to "Failed Backup: now" with a Retry button. The log at the same time shows Backup succeeded for: 'ACTIVITY' every 5–10 s. The 43e0f3f build showed the yellow "Running" state in that window and never "Failed". The reset case's clip shows the same flicker on a new wallet.
status-compare2x.2x.mp4
Reset the wallet, create a new one, and keep each wallet's backup separate ✅ After a reset and a new wallet in the same app process, the new wallet's backup restored with only its own data.
Wallet A had the profile WalletA1686 and GBP. I reset it from Settings > Security > Reset wallet and created wallet C with EUR and no profile, without restarting the app. The log shows the wipe, then VSS client setup for wallet[0], the LDK client set up again, and C's SETTINGS backup succeeding.
Restoring C's phrase on the second emulator showed EUR and the "Your Name" placeholder. So C's uploads went to C's own store, and none of A's data was there. Restoring A's phrase right after the reset brought back GBP and WalletA1686; I checked that one by reading the screen.
| Reset and new wallet | Restore of the new wallet |
|---|---|
wipe-new-wallet2x.2x.mp4 |
restore-c2x.2x.mp4 |
Restore a backed-up wallet that has a Pubky profile ✅ Plain "Wallet restored" text, then Home with WalletA1686 and a receive address.
The log shows Full restore success. METADATA's first backup was refused before identity activation and its retry succeeded 6 s later. ACTIVITY, SETTINGS, WALLET and BLOCKTANK backups succeeded.
restore-a2x.2x.mp4
Restore while the Pubky service is unreachable ✅ The wallet restored and was usable, and the profile came back by itself 15 s after Pubky was reachable.
On the emulator I allowed the app only DNS, VSS and Electrum, Blocktank and the price feed, and rejected everything else. The restore finished with "Your wallet and balance are back, but Bitkit could not restore: Profile, Contacts, Tags." Home showed the "Your Name" placeholder and a receive address.
During the outage METADATA backups were refused and WALLET retried at 10, 20 and 40 s (log). After I lifted the block, Home showed WalletA1686 with no action from me. WALLET and METADATA backups then succeeded. An earlier unrecorded run gave the same result.
| Restore during the outage | After unblocking |
|---|---|
restore-outage2x.2x.mp4 |
restore-outage-after2x.2x.mp4 |
Retry a failed backup from Settings > Backups ✅ A Settings backup that failed while VSS was blocked went through when I tapped Retry.
With VSS rejected on the emulator, a currency change failed to back up (Backup failed for: 'SETTINGS'). The row showed "Failed Backup" with Retry. After I lifted the block and tapped Retry, the log shows Backup starting for: 'SETTINGS' with no Scheduling backup line before it, which is the Retry path. It then shows Backup succeeded, and the row read "Latest Backup: now".
retry-backup2x.2x.mp4
Restart the Lightning node from the LDK debug screen ✅ The node restarted and backups kept succeeding.
Dev Settings > LDK > Restart. The log shows Node restarted successfully, then a completed sync, then WIDGETS, METADATA and ACTIVITY backups succeeding within 15 s.
restart-node2x.2x.mp4
Reset the network graph from recovery mode ✅ The graph was cleared from VSS, the app relaunched and the node started.
bitkit://recovery-mode > Reset Network Graph > Yes. The toast read "Network Graph Reset". The log shows Cleared network graph from VSS, the relaunch, Node started and the LDK VSS client set up again. Home opened.
reset-graph2x.2x.mp4
Check: terminal WALLET backup finding ⏭️ Not re-driven; the guard is unchanged in this commit.
Reproducing it needs an unusable saved session with no recovery secret, which the shipped app cannot produce.
Not driven:
- Allowances off and on, and Request Payment without contacts: carried from our 43e0f3f report. This commit does not change allowance code or that screen.
- Held allowance timing and required-recovery failures: these need injected holds and decoder failures the app does not have. They are covered by the earlier reports cited there.
- A shutdown or native call that fails inside
withSuspendedClients: checked in code only.
Review covered 43e0f3f..4056d2c, one commit: VssClientCoordinator and its use in VssBackupClient, VssBackupClientLdk, BackupRepo, LightningRepo, WalletConnectionRecovery and BackupsViewModel. Store ids derive the same way as VssStoreIdProvider. No device case drove resetPaymentRoutingCachesAndWait from a failed Lightning payment, because staging has no channel for one.
Twin: synonymdev/bitkit-ios#1121 got the matching coordinator at 25f25ea ("drain VSS calls before replacing wallet clients"). It changes only VssBackupClient.swift and AppReset.swift, not backup scheduling, so iOS has no counterpart of the status regression.
Reviewed by claude-opus-5-5-medium
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up since 4056d2c, at a2c58f2. Inherited baseline: review 5767887265. This pass rechecked scheduled backup status from VSS admission through the debounce, payment wait, completion, observation stop, startup cleanup, and retirement. VSS admission and drain, Lightning restart and routing refresh, METADATA/WALLET identity-snapshot refusal, restore scheduling, allowance admission, and the previously reviewed journeys stay inherited.
No new actionable code findings.
The admitted job sets Running before the five-second debounce and the wallet payment wait, then clears it when the upload finishes, observation stops, or that wallet is retired. Startup cleanup keeps the flag while the reservation is admitted or the upload is still in progress. bitkit-ios#1121 at 25f25ea leaves backup scheduling as it was: iOS already marks a scheduled backup running before its wait, and that pin's BackupService change is Blocktank refresh only. The resolved thread 4467512070 described Failed/Retry for the whole debounce; that wait is now marked Running.
The terminal WALLET backup failure remains and is unchanged: a stored identity with no snapshot still refuses the ordinary WALLET upload. No matching GitHub issue was found. Validation: build-and-test passed on this head. Unit tests were not rerun locally. Device testing: not performed in this review. The author reports J3 on an installed dev build, with Settings showing Running through the currency-change debounce and Latest Backup after acknowledgement.
Reviewer model: grok-4.7
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at a2c58f2. I reviewed b93444c (keep queued backups marked as running) and a2c58f2 (clear status for unadmitted backups).
Checked and clean:
pending.admitted = trueis the first statement insidewithWallet, so every path that writesrunning = truereaches the NonCancellable finally, which recomputesrunningfromrunningBackupsand the admitted pending entry. That holds for cancellation during the debounce, during the Paykit wait and duringtriggerBackup, and for owner changes (withSuspendedClientsjoins admitted jobs before the wipe block) andstopObservingBackups.- If the process dies during the debounce, the persisted
running = trueis cleared by the nextstartObservingBackups. A newly admitted job keeps its flag (a2c58f2). - The job's own
running = truedoes not block it: it checksstatus.copy(running = category in runningBackups).shouldBackup(category). The status observer only reschedules whenshouldBackupholds, so the running true→false transition after a skipped or failed job does not loop.
Device gate: a2c58f2 (Gate_Throwaway emulator), journeys: 1 passed (backup/scheduled-backup-status.xml). Changing the currency to EUR and opening bitkit://screen/backup-settings showed Settings as Running with no Retry. It moved to "Latest Backup: now" 16 s later, with no Failed Backup message:
17:29:14.731 DEBUG [BackupRepo.kt:537] Scheduling backup for: 'SETTINGS'
17:29:19.745 DEBUG [BackupRepo.kt:625] Backup starting for: 'SETTINGS'
17:29:20.116 INFO [BackupRepo.kt:663] Backup succeeded for: 'SETTINGS'
perf: not relevant (two extra DataStore writes per scheduled backup); l10n: no UI text changed; iOS: journey recorded as Android-only in journeys/README.md.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The scheduled-backup status regression is fixed; the existing terminal-identity WALLET backup HIGH still reproduces at a2c58f2.
Findings:
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:875. An unusable saved session without a recovery secret left a new ordinary watch-only account reservation pending. The real SDK refused complete WALLET capture withidentity_error; the remote bytes and version stayed unchanged. Ordinary receiving still worked. Restoring the original credentials permitted upload again. This is additional evidence for the existing thread, not a new inline finding.
No new actionable delta findings. The scheduled Failed/Retry finding is fixed: admitted work marks Running before its wait, and cancellation cleanup preserves only active uploads or admitted replacement work.
QA for a2c58f2: Android 15 / API 35 emulator, disposable unfunded staging/regtest wallet, real VSS and native SDK. I built and verified the installed pristine APK for J3 and brief backgrounding. The remaining cases used local Debug controls to stage faults, hold activation and inspect state; all native libraries matched the pristine build. Remote-state assertions below came from Debug output and app logs. Videos play at 2×.
Planned cases: 10 passed, 1 failed, 0 unrun.
Back up ordinary wallet changes after terminal identity failure ❌ A new reservation remained pending while the remote WALLET stayed unchanged.
An invalid saved session with no recovery secret reached terminal failure. I reserved watch-only account index 1, then attempted WALLET backup. Debug checks confirmed SDK identity_error, unchanged remote bytes/version and pending work. Address allocation succeeded. Restoring the original credentials allowed a complete upload; it does not resolve the terminal-failure case.
terminal-wallet-2x.2x.mp4
Show Running while a currency backup is queued ✅ Settings showed Running without Retry, then Latest Backup after acknowledgement.
J3, pristine build: changed USD to GBP and immediately opened Data Backups. The log shows scheduling, the five-second wait, then successful SETTINGS upload. The assertion concerns the Settings row.
j3-2x.2x.mp4
Complete a queued backup after briefly backgrounding ✅ The currency change backed up and its row returned to Latest Backup.
Pristine build: changed to EUR, backgrounded briefly and returned. The brief transition cancelled the debounced node stop; exact observation-stop cancellation is covered separately below.
background-2x.2x.mp4
Clear Running when queued backup observation stops ✅ Cancellation cleared Running, retained required work and completed after observation resumed.
Debug controls scheduled a real currency change and stopped observation after one second. The status read showed running=false with required > synced. Resuming showed Running, then running=false with synced=required after acknowledgement.
observation-stop-2x.2x.mp4
Reset during a queued backup and back up a new wallet ✅ Reset completed, and the replacement wallet acknowledged its own Settings backup.
A Debug-scheduled currency change immediately preceded the normal Yes, Reset action. Wipe reached onboarding. I created a new wallet in the same process and scheduled its backups; Settings finished with no pending work or stale Running. Cross-wallet restore isolation remains covered by the earlier report cited below.
retirement-2x.2x.mp4
Keep payments and addresses gated after required wallet recovery fails ✅ Decoder failure retained the gates across restart; repair and Try Again restored receiving.
J1: malformed input reached the real WALLET decoder after the valid payload was retained. The error screen offered Try Again and Connection settings, with no Proceed action. Direct invoice and address calls returned the restore-pending error, including after another process restart. Repairing the original input and retrying produced Full restore success, cleared pending markers and restored the Savings receive QR.
required-wallet-2x.2x.mp4
Keep payments and addresses gated after required USDT recovery fails ✅ The retained-input decoder failed safely across restart, then repair restored receiving.
J1: malformed retained legacy USDT input reached restoreUsdtRecovery and raised JsonDecodingException. Payment and address admission stayed gated across restart. Repairing the original complete payload and Try Again cleared required work and restored receiving. This checks the decoder-failure gate; it is not a nonempty encrypted USDT import or funded transfer test.
required-usdt-2x.2x.mp4
Preserve a corrected Electrum endpoint while required recovery is pending ✅ The saved correction survived the held recovery, foreground cycles, restart and the older Settings backup.
J1 connection recovery: saved a distinct reachable route to staging Electrum through the normal editor. A failed RGS probe preserved that correction and the original RGS setting. With required metadata held, three foreground cycles produced one node start in that process. After restart, releasing the hold and retrying completed the original restore; stored-settings reads confirmed the corrected endpoint, original RGS and EUR.
A supplementary fresh restore checked payment/address gates after startup settled, then completed through Try Again. That separate restore began after the original correction had completed and its journal had cleared.
| Save and foreground cycles | Settled restart gates and Retry |
|---|---|
connection-save-2x.2x.mp4 |
connection-gates-2x.2x.mp4 |
Preserve the complete wallet backup while identity export is unavailable ✅ Refusal preserved remote bytes/version and pending work; recovering the identity permitted complete upload.
The Debug control removed live SDK session access. Actual WALLET export failed without replacing the complete remote record. Restoring the same secret produced a complete SDK snapshot and synchronized upload. These assertions were checked through real SDK/VSS calls and Debug output.
identity-protection-2x.2x.mp4
Restore wallet access before optional identity activation ✅ Receiving worked before authentication; release recovered the profile, one contact and a complete backup.
J1: held final activation after required imports. Completion warned about Profile, Contacts and Tags, but required markers were clear while identity/authentication were false. Home and receiving worked. Attempting WALLET backup preserved the previous bytes/version and pending work. Release recovered the saved profile and exactly one contact; a fresh SDK-bearing WALLET backup synchronized. A repeated scheduling request coalesced with the held job.
A separate cold restart with initialization held retained credentials and ordinary receiving. Releasing that hold recovered the same profile and one contact. State and contact counts were checked through Debug output.
| Final activation held and released | Restart with initialization held |
|---|---|
optional-activation-2x.2x.mp4 |
optional-restart-2x.2x.mp4 |
Back up the ordinary wallet after profile sign-out ✅ Cleared credentials allowed a synchronized ordinary WALLET backup without an identity snapshot.
The normal repository sign-out path cleared the source, secret, session, public identity and contacts. WALLET, METADATA and SETTINGS uploads then succeeded. Decoding the stored WALLET confirmed no SDK identity snapshot; Home showed Your Name and required work was clear.
identity-signout-2x.2x.mp4
Check: backup scheduler and VSS ownership regressions ✅ Both focused suites passed on the pristine head.
./gradlew --no-daemon --max-workers=4 testDevDebugUnitTest --tests to.bitkit.repositories.BackupRepoTest --tests to.bitkit.data.backup.VssClientCoordinatorTest
JUnit XML confirms 116 tests, zero failures/errors/skips. This includes the retired replacement reservation, delayed startup cleanup, observation cancellation, payment-submission wait and client-drain failure controls. The pristine APK build and diff-whitespace check passed. The full unit/lint/Compose gate was not rerun in this review.
Inherited coverage: the unchanged profile restore, Pubky outage recovery, manual backup Retry, LDK restart, graph reset and wallet-store isolation rely on the 4056d2c report. The ordinary J2 allowance journey and Request Payment without contacts rely on the 43e0f3f report. Held allowance admission/off-on cases rely on the b6751e2 report; reconnect, setup-pending, stale-profile and rejected-admission snapshot cases rely on the 1b1426f report. Their relevant mechanisms are unchanged here.
Limits: the WALLET payment wait, delayed-startup cleanup race and never-admitted replacement race were exercised by focused unit tests. No device case forced native shutdown failure, failed-payment routing-cache repair, funded payment replay, a nonempty USDT import or actual Ring-app unlink. The deferred allowance-journey wording remains tracked in bitkit-android#1766 docs: clarify delayed-contact coverage in the allowance toggle journey.
Review covered the contiguous 4056d2c..a2c58f2 delta, the scheduler/coordinator/status paths it reaches, and the existing terminal-WALLET finding. One independent reviewer inspected the delta and prior reports; I checked the conclusions at this head.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, at 25f25ea. Its scheduler already marks Running before the wait. The restore/VSS ownership intent matches; this status correction is Android-only. This was a source comparison, not an iOS device run.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The existing terminal-identity WALLET backup HIGH still reproduces at b5d5858.
Findings:
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:875. With an invalid saved session and no recovery secret, the real SDK refused WALLET capture. A new ordinary watch-only reservation stayed pending and the remote WALLET bytes/version stayed unchanged. Ordinary receiving worked; restoring the original credentials permitted a complete upload. This confirms the existing finding, with no duplicate inline comment.
No new actionable delta findings. The resolved backup-status fix, profile-retry fix and allowance-admission fix remain intact.
QA for b5d5858: Android 15 / API 35, disposable regtest wallets, staging VSS/Pubky and manually funded staging Lightning channels. I built the pinned head. J3 ran on the pristine APK; fault cases used unpushed Debug controls with all native libraries identical. State and remote-record assertions below were checked through those controls and logs. Videos play at 2×.
Back up ordinary wallet changes after terminal identity failure ❌ A new reservation stayed pending while the remote WALLET remained unchanged.
The saved session was unusable and its recovery secret absent. Actual SDK capture returned identity_error; Debug readback confirmed unchanged remote bytes/version and pending ordinary work. New address allocation and the Savings QR worked. Original-credential recovery allowed a complete upload.
terminal-wallet-2x.2x.mp4
Show Running while a currency backup is queued ✅ Settings showed Running without Retry, then Latest Backup after acknowledgement.
J3, pristine build: changed USD to GBP and opened Data Backups. The app log confirms the five-second scheduled wait and successful SETTINGS upload. The assertion concerns the Settings row.
j3-2x.2x.mp4
Restore wallet access before optional identity activation ✅ Required recovery finished while identity activation was held; release restored the profile, one contact and complete backup.
J1: with final activation held, Debug checks showed retained credentials, no identity/authentication, clear required-recovery markers and successful ordinary address allocation. Incomplete capture was refused with unchanged remote bytes/version and pending work. Release recovered the same saved profile and exactly one contact. A complete SDK-bearing WALLET synchronized; the persistent allowance pause stayed set. A QuickPay setup sheet covered the first attempted Receive tap, so the while-held receiving assertion is the direct address call.
optional-activation-2x.2x.mp4
Keep payments and addresses gated after required wallet recovery fails ⏭️ Decoder failure and refusal passed; the complete restart/repair continuation remains unverified.
J1: malformed input reached the real WALLET decoder after retaining the original valid envelope. It raised JsonDecodingException; direct payment and address calls returned WalletBackupRestorePendingError, and the persistent restore pause was set. An earlier repair recovered ordinary receiving and identity, but its optional-category result required correcting the native database setup and is not a complete acceptance pass. The clean repeat reached the decoder and safe refusals; the devices then stopped responding before its post-restart repair finished.
required-decoder-2x.2x.mp4
Disable allowances and restore the active row after enabling ⏭️ Linked wallets and usable channels were ready; the request/off/on/payment sequence did not run.
J2: both real identities were linked, a grant was accepted and both manually funded staging channels became usable. The peer prepared a 2,500-sat request. Device transports stopped answering before it was sent, so neither manual presentation while disabled nor a subsequent automatic payment is claimed at this head. The older ordinary-J2 reports do not prove the changed candidate/resolution paths here.
Keep automatic payments paused after restore until Resume ⏭️ Restore set the persistent pause, but request handling and user Resume remain unverified.
The real restore retained the accepted allowance. Debug state exposed persistedPause=true and visiblePause=RESTORED. Relaunch presentation, a covered manual request, the user-facing Resume action and a subsequent automatic payment were not completed.
Device gap: after the transport stall, reconnecting the owned devices, a separate bridge, cold boots preserving the wallets, a fresh Android 16 / API 36 image and a one-vCPU hardware-GPU route did not restore usable device access. These are setup gaps, not app failures.
Check: restore, backup ownership and allowance integration regressions ✅ The focused suites passed on the unmodified head.
./gradlew --no-daemon --max-workers=4 assembleDevDebug testDevDebugUnitTest --tests to.bitkit.repositories.BackupRepoTest --tests to.bitkit.repositories.PubkyRepoTest --tests to.bitkit.data.backup.VssClientCoordinatorTest --tests to.bitkit.repositories.PaykitAllowanceRepoTest --tests to.bitkit.viewmodels.AppViewModelSendFlowTest
JUnit XML reports no failures, errors or skips. The full unit/lint/Compose gate was not rerun locally.
Inherited coverage: original executions remain at their reported heads. The a2c58f2 report covers unchanged observation cancellation, queued-wallet retirement, retained-USDT decoder gates, Electrum correction and sign-out. The b6751e2 report covers unchanged held allowance admission, same-identity contact synchronization and disable/re-enable activation tokens. The 1b1426f report covers unchanged missing-profile retry, setup-pending suppression, stale-response refusal and rejected-admission snapshot preservation. The 4056d2c report covers unchanged VSS ownership/isolation, manual backup Retry, node restart and graph reset. Their specific mechanisms and native library pins are unchanged; these are attributed prior results, not new executions.
Limits: no device case forced native shutdown failure, failed-payment routing-cache repair, funded payment replay after process death, a nonempty encrypted USDT import, actual Ring-app unlink, or the added multiple-candidate/unchecked-limit payment branches. The delayed-contact journey prose is already deferred in bitkit-android#1766 docs: clarify delayed-contact coverage in the allowance toggle journey.
Review covered the contiguous a2c58f2..b5d5858 delta, its master integration and the existing terminal-WALLET finding. Source review included the broader allowance, private-contact routing, hardware-fee, scanned-amount and dependency changes; device checks concentrate on restore/backup/allowance integration. An independent reviewer inspected the delta and prior discussion; I checked its conclusions at the pinned head.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, at eff0dadf, retains matching deferred identity work and complete-backup refusal. Its latest scheduler repairs are iOS-specific. This was a source comparison, with no iOS device run.
Unpushed Debug controls and deciding output
The controls stage faults and holds and inspect real SDK/VSS results. They do not mock a native result or change the decisions under test. The native database initializer supplies the same fixture prerequisite used in the prior reports. Unused timing helpers are included because this is the full built patch.
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 6532aa144..4a1853cb0 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -41,6 +41,9 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) {
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { Qa1686.run(requireNotNull(context), method, arg) }
+ }
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index 029624f7f..9faa3439e 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -982,6 +982,11 @@ class BackupRepo @Inject constructor(
}
connectionSession.replay()
prepareUsdtRecoveryRestore()
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-required")
+ if (gate.exists()) Logger.info("QA1686 required metadata download held", context = TAG)
+ while (gate.exists()) kotlinx.coroutines.delay(200)
+ }
var metadataReceived = false
val metadataResult = performRestore(BackupCategory.METADATA, failedCategories) { dataBytes ->
metadataReceived = true
@@ -1181,7 +1186,11 @@ class BackupRepo @Inject constructor(
// Automatic allowance payments wait for the user to resume them before any restored state is written.
paykitAllowanceStore.pauseAfterRestore()
keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes))
- val parsed = json.decodeFromString<WalletBackupV1>(String(dataBytes))
+ val walletInput = if (to.bitkit.BuildConfig.DEBUG && java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-corrupt-wallet").exists()) {
+ Logger.info("QA1686 staging malformed required WALLET input", context = TAG)
+ "invalid-json"
+ } else String(dataBytes)
+ val parsed = json.decodeFromString<WalletBackupV1>(walletInput)
var restoredAttempt: OnchainSendAttempt? = null
parsed.paykitPaymentState?.let {
// Validate every proof before writing a guard or replacing existing payment state.
diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
index 7535bd701..c50bbd2d5 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
@@ -214,12 +214,18 @@ class PaykitAllowanceRepo @Inject constructor(
}
val revision = synchronized(activationLock) { activationRevision }
withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-activation")
+ if (gate.exists()) Logger.info("QA1686 allowance activation held after revision capture", context = TAG)
+ kotlinx.coroutines.withTimeout(90_000) { while (gate.exists()) kotlinx.coroutines.delay(100) }
+ }
if (!canActivate()) return@withContext
val identityChanged = synchronized(activationLock) {
if (activationRevision !== revision) return@withContext
val changed = activeIdentity != normalizedIdentity
if (changed) activation = Activation(normalizedIdentity)
executor.activate(normalizedIdentity)
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 allowance activation; changed=$changed; token=${activation?.let { System.identityHashCode(it) }}", context = TAG)
changed
}
if (identityChanged) {
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 72303897d..d0728b1b5 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -380,6 +380,38 @@ class PubkyRepo @Inject constructor(
private suspend fun initializeForRevision(
expectedRevision: Long? = null,
): Result<Unit> = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val root = java.io.File("/data/user/0/to.bitkit.dev/files")
+ val drop = java.io.File(root, "qa1686-drop-final-activation")
+ if (expectedRevision != null && drop.exists()) {
+ initializeMutex.withLock {
+ val savedSecret = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ val savedSession = keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
+ check(!savedSecret.isNullOrBlank() || !savedSession.isNullOrBlank())
+ pubkyService.forgetSessionAccess()
+ savedSecret?.let { keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, it) }
+ savedSession?.let { keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, it) }
+ clearAuthenticatedState(clearCachedProfile = false, preserveStoredIdentity = true)
+ drop.delete()
+ val sdkField = pubkyService.javaClass.getDeclaredField("paykitSdkService").apply { isAccessible = true }
+ val sdkService = sdkField.get(pubkyService) as to.bitkit.services.PaykitSdkService
+ val providerField = sdkService.javaClass.getDeclaredField("sessionProvider").apply { isAccessible = true }
+ val provider = providerField.get(sdkService) as to.bitkit.services.PaykitSdkSessionProvider
+ provider.suspendStoredSessionAccess()
+ Logger.info("QA1686 held final activation; required imports complete; saved credentials retained", context = TAG)
+ val held = java.io.File(root, "qa1686-hold-final")
+ try {
+ while (held.exists()) delay(200)
+ } finally {
+ provider.resumeStoredSessionAccess()
+ }
+ }
+ }
+ val gate = java.io.File(root, if (expectedRevision == null) "qa1686-hold-initial" else "qa1686-hold-final")
+ if (gate.exists()) Logger.info("QA1686 optional initialization held", context = TAG)
+ while (gate.exists()) delay(200)
+ Logger.info("QA1686 optional initialization released", context = TAG)
+ }
val (restored, revision) = initializeMutex.withLock {
if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) {
return@withContext Result.success(Unit)
@@ -490,6 +522,7 @@ class PubkyRepo @Inject constructor(
}
private suspend fun initializeSession(notifyFailure: Boolean = true): Boolean {
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 session initialization entered", context = TAG) // test hook
_isRestoringSession.update { true }
try {
val savedSession = runSuspendCatching { keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) }
@@ -853,6 +886,7 @@ class PubkyRepo @Inject constructor(
return
}
val isCurrentLoad = { isCurrent(signIn) && profileWriteGeneration.get() == writeGeneration }
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 own profile read started", context = TAG) // test hook
_isLoadingProfile.update { true }
try {
@@ -862,6 +896,13 @@ class PubkyRepo @Inject constructor(
?: throw AppError("Profile not found")
}
}.onSuccess { loadedProfile ->
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-profile")
+ if (gate.exists()) Logger.info("QA1686 profile response held", context = TAG)
+ while (gate.exists()) delay(200)
+ Logger.info("QA1686 profile released; current=${isCurrentLoad()}", context = TAG)
+ }
+
var isCurrent = false
_profile.update {
isCurrent = isCurrentLoad()
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index f90d7e2f7..aa3380e48 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -1324,8 +1324,15 @@ class PaykitSdkService @Inject constructor(
admit: suspend () -> Unit,
): AllowanceRecord {
isSetup.await()
+ if (BuildConfig.DEBUG) {
+ val gate = java.io.File(context.filesDir, "qa1686-hold-accept")
+ if (gate.exists()) Logger.info("QA1686 allowance accept held before SDK lock", context = TAG)
+ kotlinx.coroutines.withTimeout(90_000) { while (gate.exists()) delay(100) }
+ Logger.info("QA1686 allowance accept waiting for SDK lock", context = TAG)
+ }
return operationLock.withLock(Priority.Interactive) {
withStateRevisionTracking(beforeOperation = admit) { handle ->
+ if (BuildConfig.DEBUG) Logger.info("QA1686 allowance accept admitted", context = TAG)
completeSdkCall { handle.acceptAllowance(counterparty, allowanceId, expectedProposalStreamItemId) }
}
}
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 9946a8d46..fd996d0d3 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -1001,6 +1001,11 @@ class AppViewModel @Inject constructor(
isPaymentRequestIdentityActivating = true
val requestRefresh = try {
+ if (to.bitkit.BuildConfig.DEBUG) {
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-contact-sync")
+ if (gate.exists()) Logger.info("QA1686 contact synchronization held before activation", context = TAG)
+ kotlinx.coroutines.withTimeout(90_000) { while (gate.exists()) kotlinx.coroutines.delay(100) }
+ }
paykitPaymentRequestRepo.activate(state.publicKey)
if (!isStillCurrent()) return
if (state.areAllowancesEnabled && areAllowancesEnabled()) {
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..a01e9cc12
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,417 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+import kotlinx.coroutines.*
+import to.bitkit.models.*
+import to.bitkit.repositories.*
+import to.bitkit.services.*
+import kotlin.time.Clock
+
+object Qa1686 { // test hook
+ private val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO)
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun sdk(): to.bitkit.services.PaykitSdkService
+ fun allowanceExecutor(): to.bitkit.repositories.PaykitAllowanceExecutor
+ fun allowances(): to.bitkit.repositories.PaykitAllowanceRepo
+ fun allowanceStore(): to.bitkit.repositories.PaykitAllowanceStore
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun accounts(): to.bitkit.data.WatchOnlyAccountStore
+ fun settings(): to.bitkit.data.SettingsStore
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String, arg: String?): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ fun file(name: String) = File(context.filesDir, "qa1686-$name")
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "qa1686RestorePause" -> "persistedPause=${d.allowanceStore().isPausedAfterRestore()}; visiblePause=${d.allowances().automaticPaymentPause.value}"
+ "qa1686ResumeAutomatic" -> { d.allowances().resumeAutomaticPayments().getOrThrow(); "resumed automatic payments; pause=${d.allowanceStore().isPausedAfterRestore()}" }
+ "qa1686StageProfile" -> {
+ d.settings().setPubkyProfileSetupPending(arg == "pending")
+ val field = d.pubkyRepo().javaClass.getDeclaredField("_profile").apply { isAccessible = true }
+ @Suppress("UNCHECKED_CAST")
+ (field.get(d.pubkyRepo()) as kotlinx.coroutines.flow.MutableStateFlow<PubkyProfile?>).value = null
+ file("sign-in").writeText(requireNotNull(d.pubkyRepo().currentSignIn()).generation.toString())
+ "cleared local profile; pending=${d.settings().isPubkyProfileSetupPending.first()}"
+ }
+ "qa1686RetryProfile" -> {
+ val started = kotlin.time.TimeSource.Monotonic.markNow()
+ repeat(3) { d.pubkyRepo().restoreSessionIfNeeded() }
+ "three reconnect calls returned in ${started.elapsedNow()}; current=${d.pubkyRepo().currentSignIn()?.generation.toString() == file("sign-in").readText()}"
+ }
+ "qa1686CompleteSetup" -> {
+ d.settings().setPubkyProfileSetupPending(false)
+ "cleared local setup pending flag"
+ }
+ "qa1686RestoreSaved" -> { d.pubkyRepo().initialize().getOrThrow(); "restored saved identity" }
+ "qa1686RejectedBackup" -> {
+ d.sdk().processPendingPrivateMessages()
+ val field = d.sdk().javaClass.getDeclaredField("cachedBackupState").apply { isAccessible = true }
+ val beforeCache = field.get(d.sdk())
+ check(beforeCache != null)
+ val before = d.sdk().backupStateVersion.value
+ var refused = false
+ try {
+ d.sdk().acceptAllowance(requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky"), "qa-no-native-call", 1uL) {
+ throw PaykitAllowanceError.Unavailable
+ }
+ } catch (error: PaykitAllowanceError.Unavailable) { refused = true }
+ check(refused)
+ check(d.sdk().backupStateVersion.value == before)
+ check(field.get(d.sdk()) === beforeCache)
+ "real SDK service refused admission; version unchanged=true; cached snapshot same=true"
+ }
+ "qa1686OpenChannel" -> {
+ d.lightning().sync().getOrThrow()
+ val peer = org.lightningdevkit.ldknode.PeerDetails(
+ nodeId = "028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc",
+ address = "34.65.86.104:9400", isConnected = false, isPersisted = false,
+ )
+ d.lightning().connectPeer(peer).getOrThrow()
+ val result = d.lightning().openChannel(peer, 200_000uL, 50_000uL).getOrThrow()
+ "opened real disposable regtest channel id=${result.userChannelId}; local funding=200000 sats; push=50000 sats"
+ }
+ "qa1686ChannelState" -> {
+ d.lightning().sync().getOrThrow()
+ "channels=${d.lightning().getChannels()?.map { "ready=${it.isChannelReady} usable=${it.isUsable} outMsat=${it.outboundCapacityMsat} inMsat=${it.inboundCapacityMsat}" }}"
+ }
+ "qa1686AllowanceJournal" -> {
+ val identity = requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky")
+ val state = d.allowanceExecutor().localState(identity)
+ "journal=${state.journal.size}; automatic=${state.journal.count { it.isAutomatic }}"
+ }
+ "qa1686Identity" -> requireNotNull(d.pubkyRepo().publicKey.value)
+ "qa1686AddPeer" -> {
+ val peer = requireNotNull(arg)
+ file("peer").writeText(peer)
+ d.pubkyRepo().addContact(peer, PubkyProfile.placeholder(peer).copy(name = "QA peer")).getOrThrow()
+ "saved real peer contact"
+ }
+ "qa1686Link" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ d.sdk().syncPaykitApp(true)
+ val link = d.sdk().ensureLinkWithPeer(peer, maxAdvanceSteps = 4u)
+ d.sdk().processOutboundPrivateMessages(peer)
+ "link=$link"
+ }
+ "qa1686Propose" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ val role = if (arg == "ask") com.synonym.paykit.AllowanceLocalRole.ALLOWEE else com.synonym.paykit.AllowanceLocalRole.ALLOWER
+ val terms = PaykitAllowanceLimits(5, 50, 10_000uL, 100_000uL).terms(
+ PaykitAllowanceTime.monthStart(Clock.System.now()), PaykitAllowanceRepo.allowedPaymentEndpointIdentifiers())
+ val record = d.sdk().proposeAllowance(peer, role, terms)
+ d.sdk().processOutboundPrivateMessages(peer)
+ "proposed real allowance id=${record.allowanceId}; role=$role"
+ }
+ "qa1686Intake" -> {
+ val peer = file("peer").readText().removePrefix("pubky")
+ d.sdk().receivePrivateMessages(peer)
+ d.allowances().refresh().getOrThrow()
+ "received real messages; entries=${d.allowances().entries.value.size}"
+ }
+ "qa1686Activate" -> {
+ scope.launch {
+ try { d.allowances().activate(requireNotNull(d.pubkyRepo().publicKey.value)) }
+ catch (e: Exception) { Logger.info("QA1686 activate failed=${e.javaClass.simpleName}", context = "TestHook") }
+ }
+ "scheduled same-identity activation"
+ }
+ "qa1686Enable" -> { d.settings().setAreAllowancesEnabled(true); "enabled allowances" }
+ "qa1686Disable" -> { d.settings().setAreAllowancesEnabled(false); "disabled allowances" }
+ "qa1686AllowanceQuick" -> {
+ val f = d.allowances().javaClass.getDeclaredField("activation").apply { isAccessible = true }
+ val activation = f.get(d.allowances())
+ "activation=${activation?.let { System.identityHashCode(it) }}; enabled=${d.settings().areAllowancesEnabled.first()}; entries=${d.allowances().entries.value.size}; acceptance=${file("accept-result").takeIf { it.exists() }?.readText()}"
+ }
+ "qa1686Pause" -> {
+ d.allowances().pause()
+ "paused allowance repository"
+ }
+ "qa1686HoldLock" -> {
+ file("lock").writeText("hold")
+ scope.launch {
+ val f = d.sdk().javaClass.getDeclaredField("operationLock").apply { isAccessible = true }
+ (f.get(d.sdk()) as PaykitSdkOperationLock).withLock {
+ Logger.info("QA1686 SDK operation lock held", context = "TestHook")
+ withTimeout(90_000) { while (file("lock").exists()) delay(100) }
+ Logger.info("QA1686 SDK operation lock released", context = "TestHook")
+ }
+ }
+ "scheduled SDK lock hold (90 second bound)"
+ }
+ "qa1686ReleaseLock" -> { file("lock").delete(); "released lock gate" }
+ "qa1686Accept" -> {
+ val entry = requireNotNull(d.allowances().entries.value.firstOrNull { it.allowances.any { a -> a.isAnswerable } })
+ file("accept-result").delete()
+ scope.launch {
+ val result = if (arg == "auto") {
+ runCatching { check(d.allowances().acceptOffersFromAllowers().isNotEmpty()) }
+ } else d.allowances().accept(entry.id)
+ val value = "success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}"
+ file("accept-result").writeText(value)
+ Logger.info("QA1686 queued accept $value", context = "TestHook")
+ }
+ "queued real allowance acceptance"
+ }
+ "qa1686AllowanceState" -> {
+ val records = d.sdk().listAllowances(com.synonym.paykit.AllowanceFilter(null, null, emptyList()))
+ "native=${records.map { it.allowanceId + ":" + it.state }}; acceptance=${file("accept-result").takeIf { it.exists() }?.readText()}"
+ }
+
+ "qa1686Schedule" -> {
+ d.settings().update { it.copy(selectedCurrency = if (it.selectedCurrency == "EUR") "GBP" else "EUR") }
+ d.backupRepo().scheduleFullBackup()
+ "scheduled all categories"
+ }
+ "qa1686Stop" -> {
+ d.backupRepo().stopObservingBackups()
+ kotlinx.coroutines.delay(300)
+ val statuses = d.cache().backupStatuses.first()
+ "stopped; settings=${statuses[BackupCategory.SETTINGS]}"
+ }
+ "qa1686Start" -> {
+ d.backupRepo().startObservingBackups()
+ "started observers"
+ }
+ "qa1686Status" -> {
+ "settings=${d.cache().backupStatuses.first()[BackupCategory.SETTINGS]}; pending=${d.backupRepo().hasPendingWalletRestore()}; electrum=${d.settings().data.first().electrumServer}"
+ }
+ "qa1686ArmRestore" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ val old = d.cache().data.first().onchainAddress
+ File(context.filesDir, "qa1686-old-address").writeText(old)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ "armed real required restore with original complete remote WALLET; saved old address=${old.isNotBlank()}"
+ }
+ "qa1686CheckRestore" -> {
+ check(!d.backupRepo().hasPendingWalletRestore())
+ val old = File(context.filesDir, "qa1686-old-address").readText()
+ val next = d.wallet().newAddress().getOrThrow()
+ check(old.isBlank() || next != old)
+ "required markers clear; new ordinary address succeeds; old metadata address excluded=${old.isNotBlank()}"
+ }
+ "qa1686PrepareProvider" -> {
+ File(context.filesDir, "qa1686-provider-secret").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)))
+ File(context.filesDir, "qa1686-provider-pubky").writeText(requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky"))
+ "staged disposable provider credential files"
+ }
+ "qa1686InitCore" -> { // test hook
+ to.bitkit.async.ServiceQueue.CORE.background { // test hook
+ com.synonym.bitkitcore.initDb(to.bitkit.env.Env.bitkitCoreStoragePath(0)) // test hook
+ com.synonym.bitkitcore.updateBlocktankUrl(to.bitkit.env.Env.blocktankApiUrl) // test hook
+ } // test hook
+ "initialized real native database after app storage path became available" // test hook
+ } // test hook
+ "qa1686RestageProfile" -> {
+ check(d.pubkyRepo().publicKey.value != null) // test hook
+ val setter = d.pubkyRepo().javaClass.getDeclaredMethod("setProfile", to.bitkit.models.PubkyProfile::class.java).apply { isAccessible = true } // test hook
+ setter.invoke(d.pubkyRepo(), null) // test hook
+ val store = d.pubkyRepo().javaClass.getDeclaredField("pubkyStore").apply { isAccessible = true }.get(d.pubkyRepo()) as to.bitkit.data.PubkyStore // test hook
+ store.reset() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "staged missing own profile and empty display cache for active saved identity; optional enrichment scheduled" // test hook
+ }
+ "qa1686Retry" -> {
+ val original = requireNotNull(d.pubkyRepo().currentSignIn())
+ val started = android.os.SystemClock.elapsedRealtime()
+ repeat(3) { check(!d.pubkyRepo().restoreSessionIfNeeded()) }
+ val elapsed = android.os.SystemClock.elapsedRealtime() - started
+ check(d.pubkyRepo().isCurrent(original))
+ "three retry calls returned in ${elapsed}ms; original sign-in retained=true; profile=${d.pubkyRepo().profile.value?.name}"
+ }
+ "qa1686ConnectionFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString())
+ d.settings().update { it.copy(electrumServer = "tcp://127.0.0.1:1") }
+ "set isolated unreachable Electrum and retained required WALLET/USDT payloads"
+ }
+ "qa1686AddContact" -> {
+ val secret = d.pubkyService().deriveSecretKey("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
+ val key = "pubky" + d.pubkyService().publicKeyFromSecret(secret).removePrefix("pubky")
+ d.pubkyRepo().addContact(key, to.bitkit.models.PubkyProfile.placeholder(key).copy(name = "QA contact")).getOrThrow()
+ "saved one disposable contact"
+ }
+ "qa1686QuickState" -> {
+ "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, identity=${d.pubkyRepo().publicKey.value != null}, authenticated=${d.pubkyRepo().isAuthenticated.value}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}"
+ }
+ "qa1686SourceMarker" -> {
+ val key = requireNotNull(d.pubkyRepo().publicKey.value).removePrefix("pubky")
+ d.keychain().upsertString(Keychain.Key.SHARED_PUBKY_SOURCE.name, to.bitkit.data.sharedpubky.SharedPubkyContract.RING_SOURCE_PREFIX + key)
+ "staged adopted-source marker for the same active disposable identity"
+ }
+ "qa1686BackupPending" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && before.value.contentEquals(after.value) && before.version == after.version && pending)
+ "failed=true; remote bytes/version unchanged=true; pending=true"
+ }
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ "created disposable profile"
+ }
+ "qa1686Backup" -> {
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) d.backupRepo().triggerBackup(c).getOrThrow()
+ val wallet = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val metadata = requireNotNull(d.vss().getObject("METADATA").getOrThrow())
+ val pending = d.cache().backupStatuses.first().filterKeys { it in listOf(BackupCategory.WALLET, BackupCategory.METADATA) }.any { it.value.isRequired }
+ check(!pending)
+ "backup synchronized; sdk=${json.decodeFromString<WalletBackupV1>(wallet.value.decodeToString()).paykitSdkBackupState != null}; metadataBytes=${metadata.value.size}"
+ }
+ "qa1686UsdtFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, "invalid-json")
+ "staged malformed retained USDT recovery payload"
+ }
+ "qa1686Terminal" -> {
+ d.backupRepo().stopObservingBackups()
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name))
+ File(context.filesDir, "qa1686-original-secret").writeText(secret)
+ val session = requireNotNull(d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name))
+ File(context.filesDir, "qa1686-original-session").writeText(session)
+ d.pubkyService().forgetSessionAccess()
+ d.keychain().delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, "qa1686-invalid-session")
+ d.pubkyRepo().initialize().getOrThrow()
+ check(d.pubkyRepo().publicKey.value == null)
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val reserved = d.accounts().reserveAccountIndex(0, "qa1686-terminal-ordinary")
+ check(d.accounts().backupSnapshot().allocationState.highestAccountIndexByWallet["0"] == reserved)
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val same = before.value.contentEquals(after.value) && before.version == after.version
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && same && pending)
+ "terminal=true; ordinary watch-only index=$reserved reserved; failed=true; remote unchanged=true; pending=true; error=${failed.exceptionOrNull()?.javaClass?.simpleName}"
+ }
+ "qa1686RepairIdentity" -> {
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, File(context.filesDir, "qa1686-original-secret").readText())
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, File(context.filesDir, "qa1686-original-session").readText())
+ d.pubkyRepo().initialize().getOrThrow()
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow()
+ "recovered original identity and complete WALLET upload"
+ }
+ "qa1686Capture" -> { // test hook
+ File(context.filesDir, "qa1686-mnemonic").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) d.backupRepo().triggerBackup(c).getOrThrow() // test hook
+ check(d.vss().getObject("WALLET").getOrThrow() != null) // test hook
+ "captured complete backup and test credentials" // test hook
+ } // test hook
+ "qa1686Clipboard" -> { // test hook
+ withContext(Dispatchers.Main) { // test hook
+ context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) // test hook
+ } // test hook
+ "copied test credentials" // test hook
+ } // test hook
+ "qa1686BackupFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) // test hook
+ d.pubkyService().forgetSessionAccess() // test hook
+ check(d.pubkyService().currentPublicKey() == null) // test hook
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET) // test hook
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true // test hook
+ val same = before.value.contentEquals(after.value) && before.version == after.version // test hook
+ Logger.info("QA1686 unavailable identity: failed=${failed.isFailure}, unchanged=$same, pending=$pending", context="TestHook") // test hook
+ check(failed.isFailure && same && pending) // test hook
+ d.pubkyService().signIn(secret) // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ val fresh = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val complete = json.decodeFromString<WalletBackupV1>(fresh.value.decodeToString()).paykitSdkBackupState != null // test hook
+ val synced = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == false // test hook
+ check(complete && synced) // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "backup protection and recovered export passed" // test hook
+ } // test hook
+ "qa1686Recover" -> { // test hook
+ val secret = d.pubkyService().deriveSecretKey(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secret) // test hook
+ d.pubkyService().initialize() // test hook
+ d.pubkyRepo().restoreSessionBackupState(to.bitkit.models.PubkySessionBackupV1(kind = to.bitkit.models.PubkySessionBackupKind.LocalSeed)).getOrThrow() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "recovered identity and fresh complete backup" // test hook
+ } // test hook
+ "qa1686Refresh" -> { // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "scheduled optional enrichment" // test hook
+ } // test hook
+ "qa1686RequiredFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value) // test hook
+ File(context.filesDir, "qa1686-corrupt-wallet").writeText("hold")
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, before.value.decodeToString()) // test hook
+ "staged malformed required WALLET decoder input; retained original backup" // test hook
+ } // test hook
+ "qa1686RepairRequired" -> { // test hook
+ File(context.filesDir, "qa1686-corrupt-wallet").delete()
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ "qa1686SignOut" -> { // test hook
+ d.pubkyRepo().signOut().getOrThrow() // test hook
+ "identity removed" // test hook
+ } // test hook
+ else -> "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, sdk=${d.pubkyService().currentPublicKey() != null}, identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hookTerminal: Result: Bundle[{result=terminal=true; ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true; error=AppError}]
RepairIdentity: Result: Bundle[{result=recovered original identity and complete WALLET upload}]
AddressGate: Result: Bundle[{result=new address success=false; error=NodeNotRunningError}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=null}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=null}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Running}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
Backup: Result: Bundle[{result=backup synchronized; sdk=true; metadataBytes=20220}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=null}]
AddressGate: Result: Bundle[{result=new address success=true; error=null}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
Backup: Result: Bundle[{result=backup synchronized; sdk=true; metadataBytes=21148}]
Backup: Result: Bundle[{result=backup synchronized; sdk=true; metadataBytes=20727}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Running}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=false, node=Running}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=null}]
CheckRestore: Result: Bundle[{result=required markers clear; new ordinary address succeeds; old metadata address excluded=true}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=false, node=Running}]
BackupPending: Result: Bundle[{result=failed=true; remote bytes/version unchanged=true; pending=true}]
Backup: Result: Bundle[{result=backup synchronized; sdk=true; metadataBytes=20720}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=RESTORED}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=RESTORED}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Stopped}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=true, node=Running}]
SpendingGate: Result: Bundle[{result=payment admission blocked by required restore before invoice parsing}]
AddressGate: Result: Bundle[{result=new address success=false; error=WalletBackupRestorePendingError}]
RestorePause: Result: Bundle[{result=persistedPause=true; visiblePause=RESTORED}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Stopped}]
QuickState: Result: Bundle[{result=source=false, savedSecret=true, savedSession=true, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=true, node=Stopped}]
Reviewed by gpt-6.1-sol-high
November 6 validation at
b5d5858dThis branch now includes the upstream send-flow tests and keeps the allowance-admission regression. All 600 cases from the two parents ran locally. The full local suite passed 4,967 tests, lint reported zero findings, and all 22 commits in the live-master range verified locally and on GitHub.
GitHub tested merge tree
b1ad9c4eagainst masterebe9bc09. All 4,969 unit tests and 256 Compose cases passed. Six lint reports contained zero findings. The seven regtest E2E shards passed 39 cases on their first attempt. Settings address-type switching was disabled in the suite, and the release-branch migration jobs skipped. Hosted logs retain helper and runtime warnings. This run adds no authenticated Pubky/Paykit journey or terminal wallet-recovery result. CI · Lint · E2ECurrent backup-status correction — November 4
Signed fix
a2c58f2427635719b63263b9441b8d22a7b6c858prevents cancelled backup cleanup from inheriting Running from a replacement reservation that never entered its original wallet. Each pending reservation now records actual native admission and is removed by exact identity. Retirement drains admitted cleanup; rejected queued work cannot leave a stale Running flag. Startup status cleanup uses the same distinction. This is a two-file correction with no new public API or persisted state.Final formatting, compilation, 4,890 unit tests across 264 suites, lint and APK assembly passed with zero failures/errors/skips and zero static findings. All 1,797 frozen inputs match, complete diagnostics were inspected, and 1,947 sealed evidence files were verified. The single added regression covers retirement before and after the cancelled writer's status cleanup; it fails on the published predecessor and passes on this fix. Two affected independent source reviews are clean. All 21 live-master range commits verify locally and on GitHub.
Author J3: the frozen final ARM64 APK (
317136cc1b1385d9776f5edba2c22d4a25a5dd56109045595d1ddaf19b70609e) was installed in place on the preserved unfunded API36 ARM emulator, and installed bytes were verified. Changing EUR to GBP in the actual app showed Settings Running during its five-second queue, then Latest Backup after the VSS acknowledgement. No Settings Retry was observed. The original wallet and fixtures were preserved. This runtime case is distinct from the controlled unit retirement race; no wallet reset or complete-WALLET acceptance is claimed.The separate brief Transaction Log Failed Backup/Retry flash remains documented. Capture limitations and native/runtime diagnostics remain in the audit. The first CLI emulator launch exited after reporting boot; the single foreground SDK recovery ran the case. The emulator later accepted shutdown but exited134 after saving its snapshot. These observations are preserved and are not described as warning-free execution.
Current-head hosted CI, Lint and E2E all passed on attempt 1: 4,890 units, 256 Compose cases and 39 E2E passes, zero failures, one explicitly disabled Settings skip, and zero findings in six static reports. Six release-gated migration jobs were skipped. The synthetic checkout tree exactly matches
a2c58f24; complete logs, nested diagnostics and all 1,531 sealed evidence files were inspected and verified. APK identity is bound by producer/consumer artifact ID and digest; hosted APK bytes were not independently downloaded. Three RNBackupClient list-file timeouts and one ActivityRepo cancellation are newly observed runtime categories with unchanged base emitters; their cause remains unproven. Earlier cursor/native/bootstrap warnings and the temporary nonterminal GitHub status discrepancy are retained. This does not establish terminal complete-WALLET or new authenticated J1/J2 acceptance. Evidence:work/validation/hosted-android1686-a2c5-20261104/inspection/report.md. On predecessorb93444c3, the sole authorized Settings infrastructure retry completed with 13 passes and one explicit skip; the final unique E2E result was 39 passes/one skip, alongside 4,889 units, 256 Compose passes and zero lint findings. The original Docker port collision, runtime diagnostics and six skipped migration jobs remain preserved; that result is not transferred to this new head.Evidence:
work/validation/android1686-admitted-status-final-audit-20261104/,work/validation/android1686-admitted-status-app-audit-20261104/andwork/validation/android1686-admitted-status-20261104/publication.json. Existing QA IDs, remaining authenticated cases and terminal complete-WALLET limitations below remain unchanged.Additional external review: 5773366561 reports 10 passing cases and one failure at this exact
a2c58f2head on an unfunded API35 fixture. It confirms queued/cancelled Settings status and recovery gates, while reproducing the existing terminal-identity complete-WALLET failure:identity_errorleaves new ordinary-wallet work pending and preserves remote bytes/version. The reviewer distinguishes pristine app runs from Debug fault controls and decoder checks from nonempty USDT import. Raw install/proxy/readback artifacts were not independently inspected by the author. This is attributed external evidence, not another author-run campaign or human approval; the existing HIGH remains open.Queued backup status — November 4
b93444c35fixes 4467512070: a scheduled backup now shows Running during debounce and payment wait instead of falsely displaying Failed/Retry. Status cleanup stays inside the original wallet admission, preserves another active/pending backup, and checks startup cleanup against current state inside the persisted update. This does not change backup contents or relax complete-WALLET refusal.Final required compile, full tests, lint, formatting and DevDebug APK assembly passed on 1,797 matching inputs: 4,889 tests /264 suites, zero failures/errors/skips, and eight fresh static reports with zero findings. Two independent affected concurrency reviews are clean. The negative control retained three expected failures on the original production code; the intermediate compile/format failures remain recorded. Complete logs and structured diagnostics retain the verified existing cursor/JVM/deprecation/native-strip notices. All 20 commits against live master
44bf50ecverify locally and on GitHub (verified=true,reason=valid); new-head hosted CI is pending complete inspection.Author-run J3 used a new unfunded API36 ARM64 emulator, the installed APK SHA256
6646ff3bfcc7bc27dbb59e4e5c71d94b439c6c0a753f8594be520a481f7196e6, and ordinary staging VSS/Electrum. Changing local currency showed Settings Running without Retry, then Latest Backup after the real SETTINGS put acknowledgement. Continuous video and complete diagnostics are retained; the first delayed still capture missed the interval and is not counted as a pass. Media offsets are not wall-clock measurements. No profile, recovery phrase, wallet reset or funds were used. This finite result does not complete J1/J2 or the terminal complete-WALLET finding. The complete recording also retains two brief Transaction Log Failed/Retry frames between ordinary updates and scheduling, with no ACTIVITY upload failure logged. The required-before-running state is representable in live master44bf50ectoo; the exact runtime interleaving is inferred, not reproduced on that base. This separate pre-scheduling presentation gap remains, so no all-category/no-flash claim is made.Evidence:
work/validation/android1686-backup-status-final-audit-20261104/report.mdandwork/validation/android1686-backup-status-app-audit-20261104/report.md. Earlier results below remain tied to their stated revisions.VSS operation ownership — November 4
Signed
4056d2c00keeps app and standalone LDK backup work with its original wallet generation. Identity replacement closes both client admissions, cancels and drains setup/native calls and their caller tails, shuts down the old slots, then holds admission closed through credential changes. Each client derives its store ID from the same captured mnemonic/passphrase as its signer. Setup and ordinary calls retain their existing concurrency and readiness behavior.Backup payload capture, retries, status callbacks, routing repairs and detached node restarts retain the original owner. A canceled identity transition can rearm observer setup and pending backups; stale scheduling cannot replace a newer generation's debounce token. Explicit first-WALLET scheduling still works before node observation starts. Shutdown failure leaves admission closed. The released SDK and server protocol are unchanged.
Required final
just compile, fulljust testandjust lintpassed on the same 1,796 frozen inputs: 4,887 tests /264 suites, zero failures/errors/skips, with fresh Detekt and Android Lint SARIF/XML showing zero findings. Final formatting is clean; a subsequent local flag rename has exact reverse-hash equivalence and was checked by final lint. Complete logs, test reports, embedded Gradle diagnostics and all earlier failures were inspected. Four existing cursor-close traces and three JVM notices remain recorded against actual-base sources; no introduced compiler/static finding remains. Two independent affected concurrency/security source reviews are clean. All 19 commits against live master44bf50ecverify locally and on GitHub. Evidence:work/validation/android-vss-owner-diagnostics-20261104/report.md.At
4056d2c0036e5a4e51698e26b3d1615825dd77f5, hosted CI attempt 2 passed after the one authorized Compose retry. Compose executed 256 tests across 60 classes, zero failures and zero skips; XML, all HTML case identities, raw instrumentation and both binary UTP result files agree. The required build aggregate passed. Existing exact-tree evidence remains 4,887 unit passes, zero Detekt/Android Lint findings, and 39 general E2E passes, zero failures, one source-declared skip. Migration jobs were release-gated skips. The checkout was synthetic merge3c8fc2745652d6eb1f624ff537ec35cbe0702faawith the same tree32b8d4633b0f12fc1b9ee7d94007824c766addbdas the published head, against master44bf50ecabefe4e91d41fedbcbbb14fb132bbebc.The authorized Compose retry is now fully audited: Compose job, aggregate, and test artifact. All 256 cases passed with no skips. The original attempt's
No service published for: inputstartup failure remains preserved as a before-tests infrastructure failure. Complete logs and all 356 artifact members were inspected; check annotations contain only runner-image notices. Repeated fixture Core/database/Paykit bootstrap errors and Android resource warnings remain visible, and the non-uploaded Gradle problems HTML limits deprecation attribution. This does not establish running-node persistence quiescence, deployed migration or complete-WALLET recovery. No retry authority remains. Owner verification matched all 447 new evidence hashes and reused the sealed 475-file original audit. Evidence:work/validation/android1686-authorized-compose-retry-20261104/inspection/report.md. Existing authenticated QA and terminal complete-WALLET gaps remain open.Current composition — November 3
Signed additive merge
43e0f3fe04705d397a81f493cf8e57239a0be73eincorporates live master44bf50ecabefe4e91d41fedbcbbb14fb132bbebc. Both restore branches preserve deferred identity initialization while forwarding master’s failed optional backup categories to the completion screen. Existing required-restore, correction and cancellation gates remain in place. No published history was rewritten.Final
just format,just compile,just testandjust lintpassed on the same 1,794 inputs: 4,873 unit tests across 263 suites, zero failures or skips; Detekt and Android Lint report zero findings. Complete logs, XML, SARIF, HTML and embedded Gradle reports were audited, and the affected independent source review is clean. All 18 live-base commits are locally and GitHub signature-verified.Two test-fixture repairs were necessary after composition: supply master’s new ledger-state Boolean in the mocked allowance executor, and use a plain Application in the mocked view-model suite. An earlier run stalled with 64 IO workers blocked by production auth registrars started by that test fixture. Its complete thread dump, 695 passing results, one interrupted case and stopped-worker failure are retained. The final run used a different test order; it is not a same-order starvation reproduction or a production dispatcher fix. The initial formatting finding and pre-execution sandbox failure are also retained. Existing Gradle deprecation and JVM/cursor diagnostics remain disclosed in the audit.
All current-head hosted runs are complete and inspected: 4,873 unit tests, 256 Compose tests and 39 external E2E cases passed; Detekt and Android Lint have zero findings. The seven E2E shards passed their first whole-shard attempts; one Settings case, six migration jobs and all 14 retry steps were explicitly skipped. Synthetic merge
79371a24has exactly the published tree. Complete logs, nested XML/HTML/protobuf/logcats and 10 digest/CRC-verified report archives were audited; the owner verified all 520 evidence hashes.Compose retains Core storage-initialization diagnostics in its Hilt fixture. General E2E retains RN backup-list/restore timeouts and a METADATA safety refusal; relevant source blocks match actual master, without a base runtime or historical-cause claim. Two multi-address tap-helper retries are distinct from whole-shard attempts. These passing suites do not establish full authenticated backup/recovery. Hosted evidence:
work/validation/hosted-android1686-43e-20261103/inspection/report.md. The older hosted and runtime results below remain tied to their original revisions. No new authenticated J1, funded-payment or terminal complete-WALLET acceptance is claimed. Evidence:work/validation/android1686-master44bf-final-diagnostics-20261103/inspection/report.md; publication and exact signature receipts:work/validation/android1686-master44bf-publication-20261103.Previous-head hosted results — November 2
All hosted jobs for
b6751e22956653c4f2a80112143880b47298e30dare now inspected: 4,825 unit tests pass, all six static reports have zero findings, and 39 distinct E2E cases eventually pass with one deliberately disabled Settings case. The exact synthetic checkout tree matches this published head; the external suite is611509ee.The LNURL/transfer shard first failed two activity-card assertions and then passed its one automatic shard retry. Both failed executions and the exit-1 annotation remain recorded. Failure screenshots show the expected values in the second card; the guest clock differs from the host/deposit dates on the failed attempt. This supports a clock/order investigation, but the cause is not established. No manual CI rerun occurred. Six skipped migration jobs are not passes.
Complete logs, structured results, nested runtime streams, checksum/CRC-verified archives and failure images were inspected; the owner verified all 438 terminal evidence payloads. Runtime backup, TLS, sync and cancellation diagnostics remain disclosed. The earlier
181d664crouting failure is not waived or claimed repaired. Existing authenticated acceptance and terminal complete-WALLET gaps below remain open. Evidence:work/validation/hosted-android1686-b675-20261102/E2E-TERMINAL-REPORT.md.Routing-test diagnostics — November 1
Signed
b6751e22956653c4f2a80112143880b47298e30dadds failure-only resolved-activity and component-state diagnostics to the existing routing test. Assertions, query flags, deadlines and production behavior are preserved. It reuses the exact independently reviewed diagnostic file already present in #1688. The earlier181d664chosted failure below remains unresolved; this is not a causal repair or a waived check.The final composed candidate passed required compile, 4,825 tests / 260 suites, formatting and static checks with zero findings. All 1,778 frozen files match. Complete logs, XML/HTML/SARIF and lint partial/model outputs were inspected: Detekt reports are fresh; aggregate Android lint reports were UP-TO-DATE, with fresh changed-unit analysis also inspected. Four retained CloseGuard events are not claimed fixed. The exact-file/API applicability review is clean. All 17 live-base commits verify locally and on GitHub. Current-head hosted results are recorded above. Evidence:
work/validation/android1686-manifest-diagnostics-20261101/final-diagnostic-audit/REPORT.md.Previous-head hosted unit failure — November 1, 23:24 UTC
CI run 41360316580 failed on exact head
181d664c38a40df156cad4388168b5ab2df132c4: 4,824 tests passed and one failed, with zero skips.PubkyAuthManifestTest.ktfailed its first default-disabled route assertion forpubkyring://signup?hs=homeserver; both structural manifest checks and the new allowance off/on regression passed. The failure output omits the resolved activity and component override, so the cause is unresolved. Source equality with the actual parent does not establish a pre-existing runtime failure or infrastructure cause.The completed lint job has zero findings across all six reports. Five complete logs, all unit HTML and both digest/CRC-verified report archives were inspected; 400 sealed payloads were independently hash-verified. Seven E2E shards were pending at this snapshot, and six migration jobs skipped. No unchanged CI rerun was made. The diagnostic descendant is being inspected before selecting a repair; this failed required check is not waived. Evidence:
work/validation/hosted-android1686-181d-20261101-current-a/REPORT.md.Allowance off/on recovery — November 1
Signed
181d664c38a40df156cad4388168b5ab2df132c4fixes the lost activation reported in 4437596764. Turning allowances off still revokes queued work immediately. A pause revision now makes the latest enabled contact/settings state distinct, so turning them back on during held admission schedules a fresh activation for the current identity. Old queued acceptance remains revoked; identity, deletion and live-settings checks are retained.The actual AppViewModel collector and real allowance repository reproduced the failure before the fix and pass afterward, using mocked SDK/executor resources. Final required compile, 4,825 tests /260 suites, formatting and six complete static reports passed with zero failures/skips/findings. All 1,778 frozen files match. Full logs and structured diagnostics were inspected; four CloseGuard warnings and earlier failed attempts remain recorded. Two independent concurrency reviews are clean. All 16 live-master range commits verify locally and on GitHub (
verified=true,reason=valid).The existing allowance-off journey now describes the Android delayed-contact-loading variant. The author has not run that held-boundary authenticated app case; component evidence is not a device pass. Current-head hosted CI is pending complete inspection. The separate terminal complete-WALLET finding remains open. Evidence:
work/validation/android1686-allowance-resume-final-audit/REPORT.mdandwork/validation/android1686-allowance-resume-publication/.Hosted validation update — November 1, 22:16 UTC
Exact-head hosted checks are complete: 4,824 unit tests pass, six static reports have zero findings, and 39 distinct end-to-end cases pass on their first executions.
settings_08remains an explicit disabled skip; skipped migration jobs are not passes. Complete logs and seven checksum/CRC-verified end-to-end archives were inspected. Nonfatal runtime diagnostics remain recorded, without a blanket clean-runtime claim. Terminal complete-WALLET availability and the existing authenticated acceptance gaps remain open.Setup-pending and activation follow-up — November 1
Signed
1b1426fbebeccd616c29311fa5bf652089727a84stops reconnect recovery from repeatedly fetching a deliberately absent profile while local profile setup is still pending. Once setup completes, missing restored profiles remain eligible for recovery. The actual repository regression failed before repair (six unexpected resolver calls over three reconnects) and passes afterward. This head also includes the reviewed shared allowance fixes: revocable contact/dispatcher activation and rejection before SDK backup mutation tracking.Final required compile, full unit tests, lint and formatting passed: 4,824 tests, zero failures/skips and six static reports with zero findings. All 1,778 frozen inputs match; the combined source review preserves the independent shared concurrency reviews. Complete diagnostics retain four structured CloseGuard warnings and all earlier test-fixture failures/sandbox refusal. The complete 15-commit actual-live-base range verifies locally and on GitHub. New-head hosted CI is pending; no new authenticated app acceptance is claimed. The existing terminal identity/complete-WALLET HIGH remains unresolved and is not repaired by these changes.
Description
Ordinary wallet restore finishes after required wallet and USDT recovery; optional Pubky activation and profile enrichment continue afterward. Scheduled setup preserves an identity already activated by metadata restoration, coalesces requests for the same revision, and rejects obsolete results.
The reconnect follow-up in
9ac9746aeretries a missing own profile from existing reconnect, resume and maintenance calls. It captures the original sign-in, returns without waiting for profile networking, avoids re-importing an active session, and reuses the existing profile-write and overlapping-load guards. A successful load stops these retries. The previous “Your Name” placeholder finding is addressed without adding contact refresh or payment work.Complete WALLET and METADATA capture still rejects unavailable or unreadable identity data instead of replacing a complete backup with an incomplete snapshot. Terminal identity failure can still leave newer ordinary WALLET state pending; the existing HIGH availability finding remains open.
Twin: synonymdev/bitkit-ios#1121. Parent #1685 and fee follow-up #1701 merged externally; this PR now targets
master.Out of Scope
journeys/allowances/allowances-turned-off.xml: clarify the delayed-contact variant's executable or manual steps in #1766, milestone 3.1.0. This nonblocking LOW review finding is deferred under the repository review policy. The existing seven actions do not exercise that extra boundary.Current validation — November 1
Head
78269d8a1cae1ac127be93a96fba16f7d68d1019, tree325f57e2e6f20211b805816026fa98463d1f74ad, targets live master1337f121cd2694718fb58e1c4f022d73104cccc1directly. The SDK admission callback still rejects work queued before allowance deactivation. Review 4433972339 additionally found ordinary contact sync replacing the activation for the same enabled identity. The correction now retains that activation; pause, deactivation and identity changes still invalidate it. Restore, spending and complete-backup guards are unchanged.Final required format, compile, full tests and lint pass: 4,817 tests / 260 suites, zero failures/errors/skips/retries and zero fresh Detekt/Android Lint findings. All 1,778 frozen inputs match. Complete logs, nested output, XML/HTML/SARIF were audited; the owner verified 1,651 final artifacts. Three JVM notices and four cursor-close warnings remain, with retained actual-base/earlier-run comparisons. Two shared independent concurrency reviews and one affected integration review are clean; source/test blobs exactly match the validated #1684 correction. Controlled manual and automatic regressions failed before the correction and passed afterward; these are component tests, not a new authenticated app pass.
All 14 live-base commits are locally and GitHub
true/valid. The first immediate post-push REST snapshot was stale; live-head reconciliation followed by one read-only verification completed successfully, without repeating the push. New-head hosted checks await complete inspection. Evidence:work/validation/android1686-stable-activation-20261101/andwork/validation/android1686-stable-activation-final-a/inspection/structured-report.json.The previous
7b795fdchosted CI, lint and E2E are fully audited on the exact published tree: 4,815 units, zero static findings, 39 first-attempt E2E passes and one disabled Settings case. Passing multi-address logs still contain native sync deadlines and WalletOperationTimeout; their causes remain unproven. All 462 sealed evidence files verified. These results certify only that prior head.Retained evidence and unresolved findings
The terminal complete-WALLET availability defect remains open. Review5724858087 reproduces it at predecessor
71fa609c; the complete report and unpushed Debug-control appendix were read. Its raw runtime artifacts were not independently audited and its controlled APK is not claimed identical to the published APK. No nil/stale SDK fallback, partial backup or closure is implied by this allowance repair.At predecessor
71fa609c, CI passed 4,814 units, lint had zero findings, and E2E reached 39 unique eventual passes with one disabled settings case. Multi-address attempt1 failed a 30-second ReceivedTransaction wait; attempt2 passed and skipped two already-completed cases. Complete logs, all attempts, artifacts and the exact synthetic tree were audited. The failure cause remains unproven; migration skips are not passes. Earlier missing-invoice, UI-timeout and setup failures retain their original receipts; no unchanged CI retry or causal-fix claim is made.Design / Preview
Existing layout is unchanged. The status correction uses the mapped
Back up wallet › Data Backupsscreen indocs/screens-map.md; an exact Figma frame URL is not recorded, so no node link is invented. Own-profile recovery still uses existing Home/profile presentation.QA Notes
Setup
For J3, use a separate unfunded dev wallet in a DevDebug build (
./gradlew assembleDevDebug, then install the matching ABI APK). Keep default staging VSS/Electrum available, English selected, and Debug developer mode enabled foradb shell am start -a android.intent.action.VIEW -d bitkit://screen/backup-settings to.bitkit.dev. Start at Home. A device-local recording captures the brief debounce interval when host command latency is too high; it changes no product timing. No reset, profile, seed access or funding is needed.Use the committed Debug dependencies: Core 0.8.0-rc11, Paykit 0.1.1-rc2 and VSS 0.5.26-rc1. J1 needs a disposable test-network wallet with a complete VSS backup and saved identity. Keep metadata credential replacement and required imports available; delay only final activation or optional enrichment after required recovery. Preserve existing wallets and use no live funds.
Manual Tests
1. With two disposable linked identities and a pending allowance proposal, hold the receiver immediately before SDK acceptance admission. Tap Accept, synchronize a changed contact-key set while the same identity remains enabled, then release: the proposal accepts once. Repeat after disabling/re-enabling before release: the old queued attempt must be rejected. The standard journey environment lacks the controlled SDK-admission hold; deterministic repository coverage passes, but a fresh author-run authenticated app execution remains unperformed. The earlier before-fix runtime reproduction is attributed to its reviewed revision.
2. Hold an allowance activation during contact synchronization, switch Enable Allowances off and back on, then release: the current enabled identity activates once without restarting the app; the old queued acceptance stays revoked. The author’s controlled collector/repository test passes, but the authenticated app timing gate is not available in the standard fixture.
Journeys
J3 — new
scheduled-backup-status.xml: change a local currency and observe queued Running, followed by Latest Backup after acknowledgement. Android-only scheduling regression; author-run result is recorded above.J2 —
allowances/allowances-turned-off.xml: the seven executable actions cover ordinary off/on behavior and restoration of the Active row. The description-only delayed contact-loading variant is not exercised by those actions; its executable/manual documentation is deferred to #1766. That held-boundary case remains unexecuted as an author-run app journey.J1 —
restore-during-identity-outage.xml: optional activation outage after required restore, complete-backup protection, screen exit, missing-profile reconnect, stale enrichment after identity removal/replacement, restart, and required-recovery failures retaining spending/address gates.The new reconnect action expects wallet maintenance to continue while a real profile read is held, then Home to recover after release without manual Profile navigation. The final candidate has repository-level tests with mocked native/service resources; this is not a complete authenticated app journey. The author's isolated complete-app fault controls for J1 are not yet provisioned.
External review 5688838161 reports real SDK/VSS cases and reproduces both findings at predecessor
89e2702e. Its raw nonvisual artifacts have not been independently verified by the author, and it is not acceptance evidence for the new head. The matching iOS reconnect follow-up is published in #1121 at0b77f099; its iOS evidence does not establish Android acceptance.Evidence and remaining gaps
PubkyRepoTestcovers the new retry and retained coalescing, cancellation, sign-in and profile-write guards. ExistingWalletViewModelTestandBackupRepoTestcover required recovery admission and complete-backup refusal. Full-suite success does not establish process-kill, authenticated service faults or funded payments.The terminal full-WALLET availability finding remains unresolved on both platforms. No stale SDK fallback, partial backup, independent-only writer, legacy migration activation, payment replay or authorization bypass is introduced. Full-wallet redesign and contact CAS are separate work, not substitutes for this PR's acceptance.
Models used
gpt-6-astra(reasoning:xhigh).gpt-6-astra(reasoning:xhigh).gpt-6-astra(reasoning:xhigh),gpt-6.1-sol(reasoning:high).Actual execution metadata and historical review scopes are retained.