Repository navigation
fix: decouple wallet restore from profile refresh - #1686
ben-kaufman wants to merge 17 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
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the entire PR at 217b42ef against merge base e28307b9, after the comparison base changed. No coverage was inherited. Targeted restore, profile-retry and backup contracts were compared with iOS 0b77f099; this was not a full iOS review.
No new actionable code findings.
The existing HIGH terminal-identity WALLET backup concern remains unresolved: newer ordinary wallet state can remain unbacked up while identity recovery fails. The earlier redundant-import and missing-profile retry mechanisms are addressed in the pinned source and regression tests.
Validation: hosted CI passed 4,709 tests with zero failures or skips, including the changed regressions; lint succeeded. Both used a synthetic merge with the exact reviewed tree. Local focused Gradle tests stopped before execution because the Android SDK is unavailable; diff-whitespace and changed journey XML checks passed. E2E is still running.
Device testing: not performed in this review. Authenticated J1 acceptance remains unverified here; provision its selective identity/profile fault controls and resolve the terminal backup concern before treating the change as ready for a complete device pass.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal-identity WALLET backup failure still reproduces at 217b42e.
Findings: No new findings in the contiguous delta 9ce4302..217b42e.
- Existing HIGH — terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:784. With an invalid saved session and no recovery secret, a new ordinary watch-only reservation remained pending. The real SDK returnedidentity_errorbefore the explicit completeness guard; remote WALLET bytes/version stayed unchanged. Restoring the original credentials allowed complete upload. This adds evidence to the acknowledged report; it is not a duplicate finding.
QA for 217b42e: Android 16 / API 36, Pixel 7 emulator, disposable unfunded staging/regtest wallet. I built the unmodified head and then used Debug controls to stage credentials and retained recovery inputs and inspect actual SDK/VSS state. The installed APK matched its build; native libraries matched the unmodified APK. Production source/build inputs matched the pinned head. Videos play at 2×; each preserved all changed frames from its original.
Ordinary wallet backup after terminal identity failure ❌ Debug comparisons confirmed newer ordinary state remained pending.
The real resolver rejected an invalid saved session with no recovery secret. Reserved watch-only account index 1 and attempted WALLET backup: SDK identity error, unchanged previous remote bytes/version, pending newer work. Address allocation and Receive remained usable. Restoring the original credentials restored complete WALLET upload. Credential, reservation and remote-backup assertions were checked through Debug output and app logs.
terminal-backup2x.2x.mp4
Ordinary wallet and Receive during terminal identity failure; backup assertions were checked separately.
J1 — required wallet recovery blocks payment links ✅ Required recovery retained operation gates across restart, then repair restored access.
Staged a malformed retained WALLET envelope. Payment links did not open payment review; direct invoice admission and address allocation returned WalletBackupRestorePendingError. Restart retained the restricted error screen with no Proceed action. An explicit retry rejected the shared envelope in restoreUsdtRecovery(), before the later WALLET import; that decoder result was checked separately in app logs. The earlier isolated WALLET-import decoder evidence remains at its original revision.
Repairing the original envelope and Retry completed recovery. Address allocation succeeded, clearing recovery did not replay the old link, and a fresh link reached the normal insufficient-balance check. Gate and decoder assertions were checked through Debug output and logs.
required-wallet2x.2x.mp4
Payment-link refusal, restart into required recovery, repair and restored Home.
J1 — required USDT recovery blocks payment links ✅ Decoder failure retained operation gates; repair restored ordinary receiving.
Staged a malformed retained USDT envelope. A payment link remained outside review; invoice admission and address allocation were blocked. Restart retained the restricted screen; Try Again rejected the real envelope decoder and offered no Proceed action. Restored the original envelope, retried successfully and opened Receive. Decoder and admission results were checked through Debug output and app logs.
required-usdt-final2x.2x.mp4
Required-recovery error and retry, restored wallet, then ordinary receiving.
Check: unmodified build and focused regressions ✅ The pinned head built and every selected suite passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting BackupRepoTest, PubkyRepoTest, AppViewModelSendFlowTest, WalletViewModelTest, PaykitPaymentRequestRepoTest, PaykitPaymentRequestRepoSubscriptionTest, PaykitSdkServiceTest and AddContactViewModelTest. Inspected complete build/test output and available JUnit results. Diff whitespace and changed journey XML checks passed. Build output retained startup-provider, native-symbol stripping, JVM sharing and Gradle deprecation notices.
Prior passing device evidence is retained below at its original head. Restore, identity/profile, backup and native dependency inputs are unchanged. The SDK helper extraction preserves those calls; the changed shared send-admission path was driven above. These are retained passes, not new executions at 217b42e.
Profile reconnect without blocking maintenance ✅ Retained pass at `9ce4302`.
Original device report. Real profile read failed offline, then reconnect scheduled one held retry. Three overlapping maintenance calls returned in 6ms, original sign-in remained current, address allocation succeeded, and Home loaded QA restore after release without Profile navigation. Further calls after success started no new read.
Late retry after identity removal ✅ Retained pass at `9ce4302`.
Original device report. A real successful profile response triggered by restoreSessionIfNeeded was held; normal sign-out completed before release. Existing guard rejected the stale result, credentials/authentication/profile remained absent, and Contacts showed its intro.
Required restore before final identity activation ✅ Retained pass at `9ce4302`.
Original device report. After repairing original WALLET/USDT payloads, real required VSS restore completed with final activation held and saved credentials retained. SDK/repository identity was unavailable, both required markers cleared, and ordinary receiving worked. A complete-backup attempt preserved remote bytes/version and pending work. Releasing activation recovered the identity and one contact, then a fresh complete WALLET/METADATA upload succeeded.
Restore and backup without a Pubky identity ✅ Retained pass at `89e2702`.
Original device report. Restored the real no-identity VSS backup, reached wallet restored, allocated an ordinary address and synchronized WALLET and METADATA. SDK identity state was absent as expected. State and backup assertions were checked in Debug control output.
Unavailable SDK identity protects complete WALLET and recovers ✅ Retained pass at `89e2702`.
Original device report. Real SDK access loss rejected WALLET backup with prior bytes and version unchanged and pending work retained. Re-signing in with the original secret enabled fresh SDK export and complete upload. Debug assertions verified remote contents and pending state.
Restart resumes saved identity and one contact ✅ Retained pass at `89e2702`.
Original device report. Killed the app during held scheduled work and held initialization after relaunch. The ordinary wallet and receive-address allocation remained available. Releasing initialization recovered the same identity and exactly one saved contact, confirmed in UI and control output. No payment was submitted.
Connection correction survives required recovery and restart ✅ Retained pass at `89e2702`.
Original device report. Unreachable local Electrum caused the restricted recovery screen. Saved a reachable replacement; an independent failed RGS probe retained the earlier correction. Held required metadata across three foreground cycles with one node restart total in that process. Restart retained the endpoint and admission gates. Retry restored the old SETTINGS backup while preserving the explicit Electrum correction and existing RGS/USD preferences; both markers then cleared and address allocation succeeded. Raw settings and restart count were checked from app data/logs.
Ordinary wallet backup after profile sign-out ✅ Retained pass at `89e2702`.
Original device report. Successful sign-out cleared source, secret and session credentials. An ordinary new address succeeded, and WALLET/METADATA backups synchronized with no SDK identity. The Data Backups screen and control assertions verified completion.
Ordinary backup after adopted identity unlink ✅ Retained pass at `89e2702`.
Original device report. Bitkit discovered a same-signature Ring-contract provider, imported its real disposable identity through the normal choice UI, and stored an adopted source with no local secret. Normal Edit Profile unlink cleared source/session/secret credentials while the provider still advertised the identity. A new ordinary address and fresh WALLET/METADATA uploads succeeded. The provider is a controlled stand-in; Ring application UI was not tested.
No planned case remains unrun. Funded payment replay and Ring application UI were not driven. The merged contact-saving, inbox-observation and hardware-fee changes received source review and focused unit coverage; their device journeys were not driven in this run.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, inspected at 2d4fc719, matches deferred activation, missing-profile retry and complete-backup protection. Android preserves an identity already activated during required metadata restore; iOS clears in-memory authentication before scheduled initialization. The terminal backup limitation remains open on both platforms. This was a targeted twin comparison, not a full iOS review or device run.
Coverage: contiguous delta 9ce4302..217b42e, all changed paths, unresolved concerns and reached recovery/SDK code, with an independent source review. This head additively includes master e28307b9; the PR's restore implementation is unchanged from the previous review. The live head remained 217b42e at final discussion recheck.
Debug controls and deciding output
Not pushed. These controls stage recovery inputs and terminal credentials, call real repository/SDK/VSS operations, and inspect state. They do not mock SDK results or change backup/admission decisions. The optional native-initialization control in the patch was not needed or invoked.
qa1686Terminal: terminal=true; ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true
Backup failed for: 'WALLET' [AppError='code=identity_error, context=restore Pubky grant session from platform provider']
qa1686RepairIdentity: recovered original identity and complete WALLET upload
qa1686SpendingGate: payment admission blocked by required restore before invoice parsing
qa1686AddressGate: new address success=false; error=WalletBackupRestorePendingError
Restore from backup failed [JsonDecodingException: JSON input: invalid-json]
qa1686RepairRequired: repaired original required backup
Full restore success
qa1686AddressGate: new address success=true; error=null
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..c57ec93a7 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")) {
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) {
+ Qa1686.run(requireNotNull(context), method)
+ }
+ }
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
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
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,133 @@
+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 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): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "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
+ "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}"
+ }
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ "created disposable profile"
+ }
+ "qa1686Backup" -> {
+ for (c in listOf(BackupCategory.METADATA, BackupCategory.SETTINGS, BackupCategory.WALLET)) 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}"
+ }
+ "qa1686RequiredFault" -> {
+ 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, "invalid-json")
+ "staged malformed retained WALLET recovery payload"
+ }
+ "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"
+ }
+ "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
+ 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 hookReviewed by gpt-6.1-sol-high
|
@ben-kaufman Moving this PR to 3.2.0 per the 3.1.0 cut line (https://synonymworkspace.slack.com/archives/C07BJ7DNPCG/p1793307568629859): the restore and profile-deletion stacks harden behaviour that already shipped in 3.0, so they keep landing on master and ship in 3.2. |
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal-identity WALLET backup failure still reproduces at 78a0ff7.
Findings: No new findings in the contiguous delta 217b42e..78a0ff7.
- Existing HIGH — terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:814–815. An invalid saved session with no recovery secret left a new ordinary watch-only reservation pending. The real SDK returnedidentity_errorbefore the explicit completeness guard; remote WALLET bytes/version stayed unchanged. Restoring the original credentials permitted complete upload again. This adds current-head evidence to the acknowledged report.
QA for 78a0ff7: Android 16 / API 36 emulator, disposable unfunded staging/regtest wallet. I built and installed the unmodified head, then used local Debug controls to stage recovery inputs, hold optional work and inspect actual SDK/VSS state. Restore cases resumed retained recovery payloads from the real remote backup. Installed APK bytes matched their builds; native libraries matched the unmodified APK. Nonvisual assertions below were checked through those controls and app logs. Videos play at 2×.
Ordinary wallet backup after terminal identity failure ❌ New ordinary state remained pending; remote-backup comparisons were checked through Debug output.
Invalid retained session with no recovery secret reached terminal identity failure. A new ordinary watch-only reservation remained pending: the real SDK rejected WALLET capture with identity_error, leaving previous remote bytes/version unchanged. Ordinary address allocation succeeded. Restoring original credentials recovered complete upload. Remote and pending-state assertions were checked in Debug output.
terminal-backup2x.2x.mp4
Required wallet recovery blocks payment links ✅ Required recovery kept payment/address gates until repair; decoder and admission were checked in app logs.
Malformed input reached the real WALLET decoder and retained the restricted error screen. Payment link, direct invoice admission and ordinary address allocation stayed blocked, including after process restart once recovery UI was observed. Repairing the original input and Retry completed full restore and restored receiving. Decoder and admission were checked in Debug output and app logs.
required-wallet-clean2x.2x.mp4
Required USDT recovery blocks payment links ✅ The real decoder failure retained recovery gates until repair; admission was checked through Debug calls.
Malformed retained USDT envelope failed the real shared recovery decoder. Payment links, direct invoice admission and receiving stayed gated on the restricted error screen, including after restart. Repairing the original envelope and Retry completed full restore and restored ordinary address allocation. Decoder and admission assertions were checked in Debug output and logs.
required-usdt-clean2x.2x.mp4
Wallet access before final identity activation ✅ Receiving worked before authentication; Debug checks confirmed complete-backup protection and eventual recovery.
Required VSS recovery completed with final activation held, saved credentials retained, and SDK/repository identity unavailable. Ordinary receiving worked; rejected WALLET capture preserved remote bytes/version and pending work. Release recovered the identity and one contact. Metadata correctly waited for new address exclusions to finish backing up; a later complete WALLET/METADATA retry synchronized. State and backup assertions were checked in Debug output.
activation-final2x.2x.mp4
Restart during optional initialization ✅ Receiving stayed available during held initialization; Debug state confirmed the recovered profile and one contact.
Killed the app during held scheduled initialization and held initialization again on relaunch. With credentials retained and identity/authentication absent, the ordinary node and new-address allocation worked. Releasing the hold recovered the saved profile and exactly one contact. State and count were checked through Debug output; no payment was submitted.
restart2x.2x.mp4
Connection correction survives required recovery ✅ The correction and gates survived restart; settings and node-rebuild count were checked in app data/logs.
An unreachable Electrum endpoint opened restricted connection recovery. Saved the reachable replacement; a failed RGS probe retained it. Required metadata held across three foreground cycles with one node rebuild in that process; restart retained spending/address gates. After release and Retry, old SETTINGS restore preserved the explicit Electrum correction, prior RGS URL and USD preference. Markers cleared and ordinary receiving succeeded. Endpoints and rebuild count were checked in app data/logs; video shows held recovery, restart and completion.
connection-restart2x.2x.mp4
Restore and backup without a Pubky identity ✅ Restore and receiving worked; Debug checks confirmed synchronized WALLET/METADATA without SDK identity.
Real full VSS restore without Pubky identity completed after explicit Retry. Required markers cleared, new ordinary address excluded the old metadata receive address, and real WALLET/METADATA uploads synchronized with no SDK identity. UI shown in video; state, address and remote-backup assertions checked through Debug output.
no-identity2x.2x.mp4
Ordinary backup after profile sign-out ✅ Debug checks confirmed cleared credentials, ordinary receiving and synchronized backups.
Normal repository sign-out cleared source, saved session and secret. New ordinary address allocation and real WALLET/METADATA backups synchronized with no SDK identity. Credentials, remote data and pending status were checked through Debug controls; video shows ordinary wallet access.
signout2x.2x.mp4
Ordinary backup after adopted identity unlink ✅ Normal Bitkit unlink cleared adopted credentials; Debug checks confirmed subsequent ordinary backups.
Bitkit discovered and adopted a real disposable identity from a same-signature Ring-contract provider through its choice UI, with an adopted source and no local secret. Normal Edit Profile unlink cleared source/session/secret while the provider still advertised that identity. New ordinary addresses and real WALLET/METADATA uploads synchronized without SDK identity. State and backup contents were checked in Debug output; Ring application UI was not tested.
| Adopt and unlink | Ordinary backup after unlink |
|---|---|
adopted-unlink-final2x.2x.mp4 |
adopted-backup2x.2x.mp4 |
Check: unmodified build and focused regressions ✅ The pinned head built and all selected suites passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting the initialization, backup, restore ViewModel, receiving, address reservation, allowance executor/store/wire-format and shared send-flow suites. Inspected complete build/test output and JUnit XML. Changed XML and diff-whitespace checks passed. Build output retained native-symbol stripping, JVM sharing and Gradle deprecation notices. The full compile/test/lint gate was not rerun in this review.
The two profile-only passes below are retained from the 9ce4302 device report. Their identity/profile implementation and native dependency pins remain unchanged. Backup, receiving and required-recovery composition were driven again above; these are not new profile executions at this head.
Profile reconnect without blocking maintenance ✅ Retained pass at `9ce4302`.
Real profile read failed offline, then reconnect scheduled one held retry. Three overlapping maintenance calls returned in 6ms, original sign-in remained current, address allocation succeeded, and Home loaded QA restore after release without Profile navigation. Further calls after success started no new read.
Late retry after identity removal ✅ Retained pass at `9ce4302`.
A real successful profile response triggered by restoreSessionIfNeeded() was held; normal sign-out completed before release. Existing guard rejected the stale result, credentials/authentication/profile remained absent, and Contacts showed its intro.
No planned case remains unrun. Funded payment replay, nonempty USDT recovery and Ring application UI were not driven. The inherited sharing/publication, allowance-payment and balance-presentation changes received source review and focused unit coverage; their device journeys were not driven here.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, inspected at d896dba7, matches deferred activation, missing-profile retry and complete-backup protection. Android preserves an identity already activated during required metadata restoration; iOS clears in-memory authentication before scheduled initialization. The terminal backup limitation remains open on both platforms. This was a targeted comparison, not a full iOS review or device run.
Review covered the contiguous delta 217b42e..78a0ff7, all changed paths, the open backup concern and reached restore/identity/SDK code, with an independent source review. This head additively includes 6d99ad38; the child restore/profile implementation is preserved.
Debug controls and deciding output
Not pushed. These controls stage recovery inputs and terminal credentials, hold final activation or metadata reads, invoke real repository/SDK/VSS operations and inspect state. They do not mock SDK results or change backup/admission decisions. The public BIP39 test vector in the contact setup is test data. The Ring-contract stand-in is signed with the same Debug key and returns this disposable identity through the normal provider contract.
terminal=true; ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true
source=false, savedSecret=true, savedSession=true, sdk=false, identity=false, profile=null, contacts=0, pendingRestore=false, node=Running
failed=true; remote bytes/version unchanged=true; pending=true
payment admission blocked by required restore before invoice parsing
new address success=false; error=WalletBackupRestorePendingError
new address success=false; error=PublicAddressRestorePending
Full restore success
source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running
backup synchronized; sdk=true
source=true, savedSecret=false, savedSession=true, identity=true, authenticated=true, profile=QA restore
source=false, savedSecret=false, savedSession=false, identity=false, authenticated=false, profile=null, contacts=0, pendingRestore=false, node=Running
backup synchronized; sdk=false
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..c57ec93a7 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")) {
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) {
+ Qa1686.run(requireNotNull(context), method)
+ }
+ }
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 a9d095a1d..f02ec226c 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -914,6 +914,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) { dataBytes ->
metadataReceived = true
@@ -1099,7 +1104,11 @@ class BackupRepo @Inject constructor(
private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long {
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/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index e722c3dd2..1611ce5ba 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -375,6 +375,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)
@@ -481,6 +513,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) }
@@ -844,6 +877,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 {
@@ -853,6 +887,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/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 0000000..fb496ca
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,254 @@
+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 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): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "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 hookControlled Ring-contract provider
The identity and secret files below contain only the disposable fixture identity. The provider remains installed and advertises that identity after normal Bitkit unlink.
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="to.pubky.ring" android:versionCode="1" android:versionName="qa-1686">
<uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36" />
<application android:label="v4-1686-ring fixture" android:debuggable="true">
<provider android:name="to.pubky.ring.QaProvider" android:authorities="to.pubky.ring.sharedpubky" android:exported="true" />
</application>
</manifest>package to.pubky.ring;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Binder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
/** Test-only Ring contract provider. Credentials belong to this disposable QA wallet. */
public class QaProvider extends ContentProvider {
public boolean onCreate() { return true; }
private String read(String name) {
try {
return new String(Files.readAllBytes(new File(getContext().getFilesDir(), name).toPath()), StandardCharsets.UTF_8).trim();
} catch (Exception error) { throw new IllegalStateException("QA identity fixture unavailable", error); }
}
public Cursor query(Uri uri, String[] projection, String selection, String[] args, String sort) {
if (getContext().getPackageManager().checkSignatures(getContext().getApplicationInfo().uid, Binder.getCallingUid()) != PackageManager.SIGNATURE_MATCH) {
throw new SecurityException("Only the signed QA app can read this fixture");
}
String key = read("pubky");
if (uri.getPath().equals("/v1/identities")) {
MatrixCursor rows = new MatrixCursor(new String[]{"pubky"});
rows.addRow(new Object[]{key});
return rows;
}
MatrixCursor rows = new MatrixCursor(new String[]{"pubky", "secret_key"});
if (uri.getPath().equals("/v1/identities/" + key + "/credential")) rows.addRow(new Object[]{key, read("secret")});
return rows;
}
public String getType(Uri uri) { return null; }
public Uri insert(Uri uri, ContentValues values) { throw new UnsupportedOperationException(); }
public int delete(Uri uri, String selection, String[] args) { throw new UnsupportedOperationException(); }
public int update(Uri uri, ContentValues values, String selection, String[] args) { throw new UnsupportedOperationException(); }
}Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal-identity WALLET backup failure still reproduces at 71fa609.
Findings: No new findings in the contiguous delta 78a0ff7..71fa609.
- Existing HIGH — terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:814–815. An unusable saved session without a recovery secret left a new ordinary watch-only reservation pending. The real SDK returnedidentity_errorbefore the completeness guard; the previous remote WALLET bytes/version stayed unchanged. Restoring the original credentials permitted complete upload again. This confirms the acknowledged report with new-head evidence.
QA for 71fa609: Android 16 / API 36 emulator, disposable unfunded staging/regtest wallet. I built and installed the unmodified head, then used local Debug controls to stage recovery inputs, hold final activation and inspect real SDK/VSS state. Installed APK bytes matched their builds, and the controlled APK retained identical native libraries. Restore checks resumed original required payloads from the complete remote backup. Nonvisual assertions are identified below. Videos play at 2×.
Ordinary wallet backup after terminal identity failure ❌ New ordinary state remained pending; remote-backup comparisons were checked through Debug output.
Invalid saved session without a recovery secret reached terminal identity failure. A new ordinary watch-only reservation remained pending: the real SDK rejected WALLET capture with identity_error, preserving previous remote bytes/version. Ordinary receiving worked. Restoring the original credentials allowed a complete WALLET upload. Nonvisual assertions were checked through Debug controls and app logs.
terminal-backup2x.2x.mp4
Required wallet recovery blocks payment links ✅ Required recovery retained operation gates across restart; decoder and admission were checked in app logs.
Malformed input reached the real WALLET decoder. Restricted recovery blocked payment links, direct invoice admission and new addresses, including after process restart. Repair and Retry completed full restore and restored receiving. Decoder, admission and restart assertions were checked in app logs and Debug output.
The clip shows the restricted retry, repair and restored receiving. Restart and direct admission were checked in the full recorded sequence and app logs.
required-wallet-final2x.2x.mp4
Required USDT recovery blocks payment links ✅ Decoder failure retained recovery gates across restart; repair restored receiving.
The real retained-USDT decoder rejected malformed input and kept payment links, direct invoice admission and receiving gated, including after restart. Repair and Retry completed full restore and restored ordinary receiving. Decoder and admission were checked through Debug output and app logs.
The clip shows the restricted retry, repair and restored receiving. Restart and direct admission were checked in the full recorded sequence and app logs.
required-usdt-final2x.2x.mp4
Wallet access before final identity activation ✅ Wallet access resumed before authentication; Debug checks confirmed backup protection and eventual recovery.
Required VSS recovery completed with final activation held, saved credentials retained and SDK/repository identity unavailable. Ordinary receiving worked and rejected WALLET capture preserved remote bytes/version plus pending work. After release, normal maintenance recovered the profile and exactly one contact after shared_state_busy retries. A later complete WALLET/METADATA upload synchronized once new address exclusions were acknowledged. Nonvisual state was checked in Debug output and logs.
The profile recovered about 68 seconds after release while normal retries encountered SDK shared_state_busy. Complete backup subsequently synchronized; an earlier METADATA attempt correctly waited for new address exclusions. Timing and remote-state assertions were checked in logs and Debug output.
activation2x.2x.mp4
Request payment without saved contacts ✅ Request Payment opened Contacts with Add Contact when no contacts were saved.
With a real signed-in profile and no saved contacts, the Payments tab showed Request Payment and tapping it opened Contacts with the Add Contact sheet. The route was checked through the visible UI and semantic identifiers; fixture identity/count were checked separately.
request-no-contacts-final2x.2x.mp4
Check: unmodified build and focused regressions ✅ The pinned head built and every selected suite passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting PubkyRepoTest, BackupRepoTest, WalletViewModelTest, the allowance executor/repository suites, settings store/ViewModel suites and shared send-flow suite. Inspected complete build/test output and JUnit XML. Diff-whitespace and changed journey XML checks passed. Output retained startup-provider, native-symbol stripping, JVM sharing and Gradle deprecation notices. The full compile/test/lint gate and Compose instrumentation were not rerun in this review.
Prior passing device evidence is retained below at its original head. The restore, wallet, Pubky/profile and backup implementation and dependency/build pins are unchanged. The added SDK automatic-acceptance callback does not change session-provider or backup operations. Required-recovery admission and held activation were driven again above; these retained cases are not new executions at this head.
Restore and backup without a Pubky identity ✅ Retained pass at `78a0ff7`.
Original device report. Real full VSS restore without Pubky identity completed after explicit Retry. Required markers cleared, new ordinary address excluded the old metadata receive address, and real WALLET/METADATA uploads synchronized with no SDK identity. UI shown in video; state, address and remote-backup assertions checked through Debug output.
Restart during optional initialization ✅ Retained pass at `78a0ff7`.
Original device report. Killed the app during held scheduled initialization and held initialization again on relaunch. With credentials retained and identity/authentication absent, the ordinary node and new-address allocation worked. Releasing the hold recovered the saved profile and exactly one contact. State and count were checked through Debug output; no payment was submitted.
Connection correction survives required recovery ✅ Retained pass at `78a0ff7`.
Original device report. An unreachable Electrum endpoint opened restricted connection recovery. Saved the reachable replacement; a failed RGS probe retained it. Required metadata held across three foreground cycles with one node rebuild in that process; restart retained spending/address gates. After release and Retry, old SETTINGS restore preserved the explicit Electrum correction, prior RGS URL and USD preference. Markers cleared and ordinary receiving succeeded. Endpoints and rebuild count were checked in app data/logs; video shows held recovery, restart and completion.
Ordinary backup after profile sign-out ✅ Retained pass at `78a0ff7`.
Original device report. Normal repository sign-out cleared source, saved session and secret. New ordinary address allocation and real WALLET/METADATA backups synchronized with no SDK identity. Credentials, remote data and pending status were checked through Debug controls; video shows ordinary wallet access.
Ordinary backup after adopted identity unlink ✅ Retained pass at `78a0ff7`.
Original device report. Bitkit discovered and adopted a real disposable identity from a same-signature Ring-contract provider through its choice UI, with an adopted source and no local secret. Normal Edit Profile unlink cleared source/session/secret while the provider still advertised that identity. New ordinary addresses and real WALLET/METADATA uploads synchronized without SDK identity. State and backup contents were checked in Debug output; Ring application UI was not tested.
profile reconnect without blocking maintenance ✅ Retained pass at `9ce4302`.
Original device report. Real profile read failed offline, then reconnect scheduled one held retry. Three overlapping maintenance calls returned in 6ms, original sign-in remained current, address allocation succeeded, and Home loaded QA restore after release without Profile navigation. Further calls after success started no new read.
late retry after identity removal ✅ Retained pass at `9ce4302`.
Original device report. A real successful profile response triggered by restoreSessionIfNeeded was held; normal sign-out completed before release. Existing guard rejected the stale result, credentials/authentication/profile remained absent, and Contacts showed its intro.
The inherited allowance toggle already has a paired device pass; its implementation matches this composition. The inherited USDT illustration/copy and confirmation-color changes received source review, without their own device journeys here.
No planned case remains unrun. Funded payment replay, nonempty USDT import, Ring application UI and a second-wallet eligible-recipient flow were not driven. The retained adopted-identity case uses a controlled Ring-contract provider.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, inspected at 624d7a38, matches deferred activation, missing-profile retry and complete-backup protection. Android preserves an identity already activated during required metadata restore; iOS clears in-memory authentication before scheduled initialization and additionally suppresses automatic missing-profile reads while profile setup is pending. The terminal backup limitation remains open on both platforms. This was a targeted source comparison, without an iOS device run.
Review covered the contiguous delta 78a0ff7..71fa609, all changed paths, the open backup concern and reached restore/identity/SDK callers, with an independent source review. The head additively includes master 1337f121; the child restore/profile change is preserved. The live head remained 71fa609 at final discussion recheck.
Debug controls and deciding output
Not pushed. These controls stage disposable credentials and retained recovery inputs, hold optional activation, call real repositories/SDK/VSS and inspect state. They do not mock SDK results or alter backup/admission decisions. The contact setup uses a public BIP39 test vector.
terminal=true; ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true
Backup failed for: 'WALLET' [AppError='code=identity_error, context=restore Pubky grant session from platform provider']
recovered original identity and complete WALLET upload
payment admission blocked by required restore before invoice parsing
new address success=false; error=WalletBackupRestorePendingError
QA1686 staging malformed required WALLET input
JsonDecodingException in restoreWalletBackup
JsonDecodingException in restoreUsdtRecovery
Full restore success
source=false, savedSecret=true, savedSession=true, sdk=false, identity=false, profile=null, contacts=0, pendingRestore=false, node=Running
failed=true; remote bytes/version unchanged=true; pending=true
source=false, savedSecret=true, savedSession=true, identity=true, authenticated=true, profile=QA restore, contacts=1, pendingRestore=false, node=Running
backup synchronized; sdk=true
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..c57ec93a7 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")) {
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) {
+ Qa1686.run(requireNotNull(context), method)
+ }
+ }
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 a9d095a1d..f02ec226c 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -914,6 +914,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) { dataBytes ->
metadataReceived = true
@@ -1099,7 +1104,11 @@ class BackupRepo @Inject constructor(
private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long {
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/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index e722c3dd2..1611ce5ba 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -375,6 +375,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)
@@ -481,6 +513,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) }
@@ -844,6 +877,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 {
@@ -853,6 +887,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/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..fb496cad3
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,254 @@
+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 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): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "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 hookReviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — same-identity contact synchronization can reject a queued manual allowance acceptance, and the existing terminal WALLET backup failure still reproduces at 7b795fd.
Findings:
- MEDIUM — Preserve activation during same-identity contact synchronization.
app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt:192.activate()replaces the token even when the same identity remains active. A contact-key change calls it again throughsynchronizePaykitContacts(), invalidating a manual acceptance already queued for the SDK lock. The new callback refuses it withUnavailable, and the allowance UI reports an error instead of accepting the proposal. Keep the token while that identical identity remains active; replace it after pause/deactivation or an identity change. - Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:814. An invalid saved session without a recovery secret still prevents complete export: a new ordinary watch-only reservation stays pending while the previous remote bytes/version remain unchanged. Restoring the original credentials permits complete upload. This adds evidence to the acknowledged report.
QA for 7b795fd: Android 16 / API 36, disposable unfunded staging/regtest wallets, real Paykit SDK and VSS operations. Built the unmodified head, then used unpushed Debug controls for timing, disposable credentials and nonvisual state comparisons. Native libraries match the unmodified APK. Videos play at 2×; the SDK and backup assertions described below were checked separately in Debug output and app logs.
Ordinary wallet backup after terminal identity failure ❌ New ordinary state stayed pending; remote-backup comparisons were checked through Debug output.
Invalid saved session without a recovery secret reached terminal failure. A new ordinary watch-only reservation remained pending: real SDK identity_error rejected WALLET capture and the previous remote bytes/version stayed unchanged. Ordinary receiving worked; restoring the original credentials enabled complete WALLET upload. Nonvisual assertions checked through Debug controls and app logs.
The clip shows ordinary wallet/Receive access during the controlled failure and recovery; reservation, SDK error, remote bytes/version and pending state were checked through Debug controls and logs.
Disabling allowances rejects queued offers ✅ Disabled and re-enabled activations rejected old queued offers; a fresh attempt succeeded.
A real peer offer queued behind the SDK operation lock was refused after the normal disable observer paused allowances. Repeated after disabling, re-enabling and reactivating the same identity: the old attempt again failed with Unavailable. A distinct fresh automatic attempt then passed admission and the native proposal became ACCEPTED. Original-versus-fresh attempts and native state were checked in Debug output and app logs.
Manual allowance acceptance across same-identity refresh ❌ Same-identity reactivation rejected the queued acceptance; the same proposal succeeded on fresh retry.
Tapped the real allowance review Accept button while SDK admission was delayed, then queued it behind the SDK operation lock. A Debug control reactivated the same still-enabled identity without pause. The UI showed “This allowance is not available right now.” Native state stayed PROPOSED after release; a fresh tap accepted that same proposal. The token change, unchanged enabled state and native proposal states were checked in Debug output and logs; the contact-sync caller was checked in source.
Check: queued manual acceptance during contact synchronization ❌ Same-identity activation rejects the acceptance; the refresh-only control passes.
A fresh deterministic repository check held the SDK queue, started accept(entryId), called activate(identity) again without pause or an identity change, then released admission. It failed with PaykitAllowanceError.Unavailable. The otherwise identical refresh()` control passed and recorded one acceptance. The repository and admission callback are real; this component check mocks the SDK queue.
Check: unmodified build and focused regressions ✅ The pinned head built and its selected suites passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting PubkyRepoTest, BackupRepoTest, WalletViewModelTest and PaykitAllowanceRepoTest. Inspected the build output and JUnit XML. The full compile/test/lint gate and Compose instrumentation were not rerun in this review. Existing native-strip, JVM-sharing and Gradle-deprecation notices remain in the output.
The passing cases below are retained at their original heads after relevant-path comparison. This push only changes allowance acceptance and its tests plus the changelog; restore/profile/backup implementation and native dependency pins are unchanged from the preceding review. The older profile-only cases use the unchanged profile implementation; intervening backup/admission composition was separately driven in the preceding review. These are retained passes, not fresh executions at 7b795fd.
Restore and backup without a Pubky identity ✅ Retained pass at 78a0ff7.
Original device report. Real full VSS restore without Pubky identity completed after explicit Retry. Required markers cleared, new ordinary address excluded the old metadata receive address, and real WALLET/METADATA uploads synchronized with no SDK identity. UI shown in video; state, address and remote-backup assertions checked through Debug output.
Restart during optional initialization ✅ Retained pass at 78a0ff7.
Original device report. Killed the app during held scheduled initialization and held initialization again on relaunch. With credentials retained and identity/authentication absent, the ordinary node and new-address allocation worked. Releasing the hold recovered the saved profile and exactly one contact. State and count were checked through Debug output; no payment was submitted.
Connection correction survives required recovery ✅ Retained pass at 78a0ff7.
Original device report. An unreachable Electrum endpoint opened restricted connection recovery. Saved the reachable replacement; a failed RGS probe retained it. Required metadata held across three foreground cycles with one node rebuild in that process; restart retained spending/address gates. After release and Retry, old SETTINGS restore preserved the explicit Electrum correction, prior RGS URL and USD preference. Markers cleared and ordinary receiving succeeded. Endpoints and rebuild count were checked in app data/logs; video shows held recovery, restart and completion.
Ordinary backup after profile sign-out ✅ Retained pass at 78a0ff7.
Original device report. Normal repository sign-out cleared source, saved session and secret. New ordinary address allocation and real WALLET/METADATA backups synchronized with no SDK identity. Credentials, remote data and pending status were checked through Debug controls; video shows ordinary wallet access.
Ordinary backup after adopted identity unlink ✅ Retained pass at 78a0ff7.
Original device report. Bitkit discovered and adopted a real disposable identity from a same-signature Ring-contract provider through its choice UI, with an adopted source and no local secret. Normal Edit Profile unlink cleared source/session/secret while the provider still advertised that identity. New ordinary addresses and real WALLET/METADATA uploads synchronized without SDK identity. State and backup contents were checked in Debug output; Ring application UI was not tested.
profile reconnect without blocking maintenance ✅ Retained pass at 9ce4302.
Original device report. Real profile read failed offline, then reconnect scheduled one held retry. Three overlapping maintenance calls returned in 6ms, original sign-in remained current, address allocation succeeded, and Home loaded QA restore after release without Profile navigation. Further calls after success started no new read.
late retry after identity removal ✅ Retained pass at 9ce4302.
Original device report. A real successful profile response triggered by restoreSessionIfNeeded was held; normal sign-out completed before release. Existing guard rejected the stale result, credentials/authentication/profile remained absent, and Contacts showed its intro.
Request payment without saved contacts ✅ Retained pass at 71fa609.
Original device report. With a real signed-in profile and no saved contacts, the Payments tab showed Request Payment and tapping it opened Contacts with the Add Contact sheet. The route was checked through the visible UI and semantic identifiers; fixture identity/count were checked separately.
Required wallet recovery blocks payment links ✅ Retained pass at 71fa609.
Original device report. Malformed input reached the real WALLET decoder. Restricted recovery blocked payment links, direct invoice admission and new addresses, including after process restart. Repair and Retry completed full restore and restored receiving. Decoder, admission and restart assertions were checked in app logs and Debug output.
Required USDT recovery blocks payment links ✅ Retained pass at 71fa609.
Original device report. The real retained-USDT decoder rejected malformed input and kept payment links, direct invoice admission and receiving gated, including after restart. Repair and Retry completed full restore and restored ordinary receiving. Decoder and admission were checked through Debug output and app logs.
Wallet access before final identity activation ✅ Retained pass at 71fa609.
Original device report. Required VSS recovery completed with final activation held, saved credentials retained and SDK/repository identity unavailable. Ordinary receiving worked and rejected WALLET capture preserved remote bytes/version plus pending work. After release, normal maintenance recovered the profile and exactly one contact after shared_state_busy retries. A later complete WALLET/METADATA upload synchronized once new address exclusions were acknowledged. Nonvisual state was checked in Debug output and logs.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, inspected at d83a217, matches deferred initialization, missing-profile recovery and complete-backup protection. Android preserves an identity already activated during required metadata restoration; iOS clears in-memory authentication before scheduled initialization and skips automatic missing-profile reads while profile setup is pending. The terminal backup limitation remains open on both platforms. This was a targeted source comparison, without an iOS device run.
Review covered the contiguous 71fa609..7b795fd delta, all four changed paths, open concerns and reached allowance/SDK/contact callers, with an independent source review. Funded payment replay, nonempty USDT import and Ring application UI were not driven; the retained adopted-identity case uses a controlled Ring-contract provider.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal WALLET backup failure still reproduces at 78269d8. The same-identity allowance acceptance regression is fixed.
Findings: No new findings in this delta.
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:814. With an invalid retained session and no recovery secret, a new ordinary watch-only reservation stays pending: the real SDK rejects WALLET capture and the previous remote bytes/version remain unchanged. Restoring the original credentials permits complete upload. This confirms the existing report.
The same-identity activation MEDIUM is fixed at 78269d8: the original manual acceptance and automatic offer acceptance both survived reactivation of the same still-enabled identity. Disabling and re-enabling still invalidates old queued work.
QA for 78269d8: Android 16 / API 36, two disposable unfunded staging/regtest wallets, real Paykit SDK and VSS operations. Built the unmodified head, then used unpushed Debug controls to stage disposable credentials, hold real admission/SDK work and inspect nonvisual state. Both installed controlled APKs matched their build; native libraries matched the unmodified APK. Videos play at 2×. Native state, tokens, remote-backup contents and pending work were checked in Debug output and app logs.
Ordinary wallet backup after terminal identity failure ❌ New ordinary state stayed pending; remote-backup comparisons were checked through Debug output.
Invalid saved session without a recovery secret reached terminal identity failure. New ordinary watch-only account index 1 stayed pending: the real SDK rejected WALLET capture and remote bytes/version stayed unchanged. Ordinary receiving worked; restoring original credentials recovered complete WALLET upload. Credential, reservation and remote-state assertions were checked through Debug output and app logs.
terminal-backup2x.2x.mp4
Ordinary wallet access during identity failure and recovery; backup assertions were checked separately.
Manual allowance acceptance across same-identity refresh ✅ The original UI acceptance succeeded after same-identity reactivation; token and native state were checked in app logs.
Tapped Accept on a real peer allowance proposal while admission was held, queued it behind the SDK operation lock, and reactivated the same still-enabled identity. The activation token stayed unchanged and the original attempt changed the real native proposal to ACCEPTED after release. Token, settings and native state were checked through Debug output and app logs.
queued-manual2x.2x.mp4
Allowance UI during the controlled queue sequence; admission and native state were checked separately.
Automatic offer acceptance across same-identity refresh ✅ The original automatic attempt accepted the offer after same-identity reactivation; token and native state were checked in app logs.
The original automatic acceptance of a real peer offer waited behind the SDK operation lock. Reactivating the same still-enabled identity preserved its activation token; releasing the lock let that original attempt accept the native proposal. Token, enabled state, admission and native ACCEPTED state were checked through Debug output and app logs; the UI then showed the allowance active.
queued-offer-refresh2x.2x.mp4
Allowance UI during the controlled queue sequence; admission and native state were checked separately.
Disabling allowances rejects queued offers ✅ Old queued offers remained unanswered after disable and disable/re-enable; a fresh attempt succeeded, checked in app logs.
A real peer offer queued behind the SDK operation lock stayed PROPOSED after disabling allowances. Repeated after disable/re-enable of the same identity: the old attempt again stayed PROPOSED. A separate fresh automatic attempt captured the new activation, passed admission and changed the native proposal to ACCEPTED. Old versus fresh attempts, token changes, settings and native state were checked in Debug output and app logs.
queued-offer2x.2x.mp4
Allowance UI during the controlled queue sequence; admission and native state were checked separately.
Check: unmodified build and focused regressions ✅ The pinned head built and the selected suites passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting PaykitAllowanceRepoTest, PubkyRepoTest, BackupRepoTest and WalletViewModelTest; inspected build output and JUnit XML. This includes both new same-identity regressions and the retained pause/re-enable regression. The full compile/test/lint gate and Compose instrumentation were not rerun in this review. Build output retained startup-provider, native-symbol stripping, JVM sharing and Gradle-deprecation notices.
The following passes remain at their original heads. Their exercised restore, profile, wallet, backup and dependency paths are unchanged from the preceding review; this three-file delta changes allowance activation, its regressions and the changelog. Earlier profile-only evidence retains its original scope. These are retained passes, not fresh executions at 78269d8.
Restore and backup without a Pubky identity ✅ Retained pass at 78a0ff7.
Original device report. Real full VSS restore without Pubky identity completed after explicit Retry. Required markers cleared, new ordinary address excluded the old metadata receive address, and real WALLET/METADATA uploads synchronized with no SDK identity. UI shown in video; state, address and remote-backup assertions checked through Debug output.
Restart during optional initialization ✅ Retained pass at 78a0ff7.
Original device report. Killed the app during held scheduled initialization and held initialization again on relaunch. With credentials retained and identity/authentication absent, the ordinary node and new-address allocation worked. Releasing the hold recovered the saved profile and exactly one contact. State and count were checked through Debug output; no payment was submitted.
Connection correction survives required recovery ✅ Retained pass at 78a0ff7.
Original device report. An unreachable Electrum endpoint opened restricted connection recovery. Saved the reachable replacement; a failed RGS probe retained it. Required metadata held across three foreground cycles with one node rebuild in that process; restart retained spending/address gates. After release and Retry, old SETTINGS restore preserved the explicit Electrum correction, prior RGS URL and USD preference. Markers cleared and ordinary receiving succeeded. Endpoints and rebuild count were checked in app data/logs; video shows held recovery, restart and completion.
Ordinary backup after profile sign-out ✅ Retained pass at 78a0ff7.
Original device report. Normal repository sign-out cleared source, saved session and secret. New ordinary address allocation and real WALLET/METADATA backups synchronized with no SDK identity. Credentials, remote data and pending status were checked through Debug controls; video shows ordinary wallet access.
Ordinary backup after adopted identity unlink ✅ Retained pass at 78a0ff7.
Original device report. Bitkit discovered and adopted a real disposable identity from a same-signature Ring-contract provider through its choice UI, with an adopted source and no local secret. Normal Edit Profile unlink cleared source/session/secret while the provider still advertised that identity. New ordinary addresses and real WALLET/METADATA uploads synchronized without SDK identity. State and backup contents were checked in Debug output; Ring application UI was not tested.
profile reconnect without blocking maintenance ✅ Retained pass at 9ce4302.
Original device report. Real profile read failed offline, then reconnect scheduled one held retry. Three overlapping maintenance calls returned in 6ms, original sign-in remained current, address allocation succeeded, and Home loaded QA restore after release without Profile navigation. Further calls after success started no new read.
late retry after identity removal ✅ Retained pass at 9ce4302.
Original device report. A real successful profile response triggered by restoreSessionIfNeeded was held; normal sign-out completed before release. Existing guard rejected the stale result, credentials/authentication/profile remained absent, and Contacts showed its intro.
Request payment without saved contacts ✅ Retained pass at 71fa609.
Original device report. With a real signed-in profile and no saved contacts, the Payments tab showed Request Payment and tapping it opened Contacts with the Add Contact sheet. The route was checked through the visible UI and semantic identifiers; fixture identity/count were checked separately.
Required wallet recovery blocks payment links ✅ Retained pass at 71fa609.
Original device report. Malformed input reached the real WALLET decoder. Restricted recovery blocked payment links, direct invoice admission and new addresses, including after process restart. Repair and Retry completed full restore and restored receiving. Decoder, admission and restart assertions were checked in app logs and Debug output.
Required USDT recovery blocks payment links ✅ Retained pass at 71fa609.
Original device report. The real retained-USDT decoder rejected malformed input and kept payment links, direct invoice admission and receiving gated, including after restart. Repair and Retry completed full restore and restored ordinary receiving. Decoder and admission were checked through Debug output and app logs.
Wallet access before final identity activation ✅ Retained pass at 71fa609.
Original device report. Required VSS recovery completed with final activation held, saved credentials retained and SDK/repository identity unavailable. Ordinary receiving worked and rejected WALLET capture preserved remote bytes/version plus pending work. After release, normal maintenance recovered the profile and exactly one contact after shared_state_busy retries. A later complete WALLET/METADATA upload synchronized once new address exclusions were acknowledged. Nonvisual state was checked in Debug output and logs.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, inspected at d83a217, matches deferred initialization, missing-profile recovery and complete-backup protection. Its saved-contact observer does not reactivate allowances, and initial automatic-session activation deduplicates the same identity, so this Android contact-sync correction does not require the same token change there. The terminal backup limitation remains open on both platforms. This was source comparison, without an iOS device run.
Review covered the contiguous 7b795fd..78269d8 delta, all three changed paths, open concerns and reached activation, contact-sync, SDK and backup callers, with an independent source review. No planned case remains unrun. Funded payment replay, nonempty USDT import and Ring application UI were not driven; retained adopted-identity evidence uses a controlled Ring-contract provider.
Debug controls and deciding output
Not pushed. The controls hold real admission and the SDK operation lock until explicit release, stage terminal credentials only in disposable wallets, and read actual SDK/VSS state. They do not mock SDK results or change production backup/admission decisions.
terminal=true; ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true
Backup failed for: 'WALLET' [AppError='code=identity_error, context=restore Pubky grant session from platform provider']
ordinary address success=true; error=null
recovered original identity and complete WALLET upload
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..1cb3140ee 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")) {
+ 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/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
index 80c5b0311..a8f18918b 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt
@@ -190,6 +190,7 @@ class PaykitAllowanceRepo @Inject constructor(
withContext(ioDispatcher) {
val identityChanged = activeIdentity != normalizedIdentity
if (identityChanged) activation = Activation(normalizedIdentity)
+ if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 allowance activation; changed=$identityChanged; token=${activation?.let { System.identityHashCode(it) }}", context = TAG)
executor.activate(normalizedIdentity)
if (identityChanged) {
manualRequestSignatures.clear()
diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
index 3ac3016d1..1be322e0a 100644
--- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
+++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt
@@ -106,6 +106,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.lightningdevkit.ldknode.Network
import to.bitkit.BuildConfig
@@ -1324,9 +1325,16 @@ 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)
+ withTimeout(90_000) { while (gate.exists()) delay(100) }
+ }
+ if (BuildConfig.DEBUG) Logger.info("QA1686 allowance accept waiting for SDK lock", context = TAG)
return operationLock.withLock(Priority.Interactive) {
withStateRevisionTracking { handle ->
admit()
+ if (BuildConfig.DEBUG) Logger.info("QA1686 allowance accept admitted", context = TAG)
completeSdkCall { handle.acceptAllowance(counterparty, allowanceId, expectedProposalStreamItemId) }
}
}
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 0000000..3bd2ed6
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,176 @@
+package to.bitkit.dev
+
+import android.content.Context
+import android.os.Bundle
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.first
+import to.bitkit.data.CacheStore
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.WatchOnlyAccountStore
+import to.bitkit.data.backup.VssBackupClient
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.di.json
+import to.bitkit.models.*
+import to.bitkit.repositories.*
+import to.bitkit.services.*
+import to.bitkit.utils.Logger
+import java.io.File
+import kotlin.time.Clock
+
+/** Unpushed timing and state controls for disposable regtest wallets only. */
+object Qa1686 {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ @EntryPoint @InstallIn(SingletonComponent::class)
+ interface Dependencies {
+ fun keychain(): Keychain
+ fun backupRepo(): BackupRepo
+ fun pubkyRepo(): PubkyRepo
+ fun pubkyService(): PubkyService
+ fun vss(): VssBackupClient
+ fun cache(): CacheStore
+ fun wallet(): WalletRepo
+ fun accounts(): WatchOnlyAccountStore
+ fun settings(): SettingsStore
+ fun lightning(): LightningRepo
+ fun sdk(): PaykitSdkService
+ fun allowances(): PaykitAllowanceRepo
+ }
+
+ suspend fun run(context: Context, method: String, arg: String?): Bundle {
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java)
+ fun file(name: String) = File(context.filesDir, "qa1686-$name")
+ val text = try {
+ when (method) {
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ d.settings().setAreAllowancesEnabled(false)
+ "created disposable profile; allowances disabled for fixture preparation"
+ }
+ "qa1686Identity" -> requireNotNull(d.pubkyRepo().publicKey.value)
+ "qa1686Backup" -> {
+ for (c in listOf(BackupCategory.WALLET, BackupCategory.METADATA, BackupCategory.SETTINGS)) {
+ d.backupRepo().triggerBackup(c).getOrThrow()
+ }
+ val wallet = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ check(json.decodeFromString<WalletBackupV1>(wallet.value.decodeToString()).paykitSdkBackupState != null)
+ check(d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == false)
+ "complete WALLET synchronized with SDK state"
+ }
+ "qa1686Terminal" -> {
+ d.backupRepo().stopObservingBackups()
+ file("original-secret").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)))
+ file("original-session").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name)))
+ 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 backup = 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(backup.isFailure && same && pending)
+ "terminal=true; ordinary watch-only index=$reserved reserved; failed=true; remote unchanged=true; pending=true; error=${backup.exceptionOrNull()?.javaClass?.simpleName}"
+ }
+ "qa1686RepairIdentity" -> {
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, file("original-secret").readText())
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, file("original-session").readText())
+ d.pubkyRepo().initialize().getOrThrow()
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow()
+ "recovered original identity and complete WALLET upload"
+ }
+ "qa1686AddressGate" -> {
+ val address = d.wallet().newAddress()
+ "ordinary address success=${address.isSuccess}; error=${address.exceptionOrNull()?.javaClass?.simpleName}"
+ }
+ "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(1, 10, 100uL, 1000uL).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()}"
+ }
+ else -> "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}"
+ }
+ } catch (e: Exception) { "ERROR: ${e.javaClass.simpleName}: ${e.message}" }
+ Logger.info("QA1686 $method: $text", context = "TestHook")
+ return Bundle().apply { putString("result", text) }
+ }
+}Reviewed by gpt-6.1-sol-high
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed to 78269d8. Two LOWs inline, no HIGH or MEDIUM.
My two earlier threads are fixed. The redundant session re-import is still skipped at PubkyRepo.kt:152-156, and 9ac9746 retries the missing own profile on reconnect, the option I offered. The first LOW is a gap in that retry.
Checked:
7b795fdand78269d8:- The activation token is replaced only when the identity changes, and
pause()/deactivate()null it, so a same-key disable → enable still rejects queued work. admit()runs under the SDKoperationLock, so an acceptance queued for a paused or replaced identity throwsUnavailable.- A rejected admit keeps the
acceptingOfferIdsmark, and the next refresh retries the offer.
- The activation token is replaced only when the identity changes, and
- The merges only resolve test files (
BackupRepoTest.kt,WalletViewModelTest.kt). There is no production conflict resolution.
Performance-relevant: it adds repeated background work, which is the first LOW. The gate counts Failed to load profile per 10 min on Home for an identity with setup pending.
Device gate (partial): 78269d8 — not run yet. Pending: the new step 4 of wallet/restore-during-identity-outage.xml with the homeserver fault proxy (the name replaces the placeholder after reconnect without opening Profile), and the setup-pending count above. Device results will follow in a separate review.
jvsena42
left a comment
There was a problem hiding this comment.
Device gate result. My two LOWs from the code review stay open; nothing above LOW.
Device gate: 78269d8 — Pixel_9 emulator, head APK and the master CI APK for 1337f12 (the PR's base), homeserver traffic through a local fault proxy. perf: see the counts below. l10n: n/a.
wallet/restore-during-identity-outage.xml step 4 on head: pass.
- The homeserver was held from the moment the identity activated during restore. The profile read retried and failed (
Retrying contact profile resolution, thenFailed to load profile), and the full restore still succeeded, with Home showing "Your Name". - Background → foreground with the hold still on issued a new profile read. On-chain sync, bip21 refresh, Blocktank and fx refresh, and an ACTIVITY backup all ran while that read was held.
- After release, the header switched to the restored profile name within one maintenance tick without opening Profile. The active session was not re-imported (no
Re-signed inorRestored paykit session).
Sizing my LOW (a) at PubkyRepo.kt:401: an identity in setup-pending state (signed up, Create Profile left without saving), observed for 5 minutes on each build, with one background → foreground and one airplane-mode reconnect. Each load makes 2 resolveContactProfile reads and logs one ERROR Failed to load profile [Profile not found].
| Trigger | head 78269d8: loads (reads) | master 1337f12: loads (reads) |
|---|---|---|
| Maintenance ticks | 4 (8) | 0 (0) |
| Background → foreground | 1 (2) | 0 (0) |
| Airplane-mode reconnect | 1 (2) | 0 (0) |
| Total in 5 min | 6 loads, 12 reads, 6 ERRORs | 0 |
So head re-reads the missing profile about once per 60 s tick, plus once per resume and once per reconnect, for as long as setup stays pending. The fix in the inline (&& !settingsStore.isPubkyProfileSetupPending.first()) removes this.
Skipped:
⚠️ Steps 1–3 and 5–7 ofrestore-during-identity-outage.xmlwere only run as setup for step 4, not checked as gate steps; they are unchanged by this PR's delta.⚠️ The step 4 hold/release sequence was not run on master: master has no reconnect retry to compare against.
Not posted as findings: under the hold, the WALLET category took about 40 s to restore instead of about 1 s (not compared on master); and for 3 s right after the hold was released, 13 acquire Pubky write lock (HTTP 405) warnings appeared once and never recurred.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the entire PR at 78269d8 against merge base 1337f121. The comparison base moved from e28307b to 1337f12, and that master range changes BackupRepo.kt and BackupRepoTest.kt. No coverage baseline was supplied, so inherited coverage is empty.
No new actionable code findings.
Scheduled restore keeps an identity that metadata restore already signed in, and reconnect, resume, and maintenance retry a missing profile for that sign-in. Same-identity contact sync keeps the allowance admission token; pause, deactivation, and an identity change still reject a queued acceptance. WALLET and METADATA backups still fail closed when identity data cannot be read.
These concerns still match this revision and stay on their existing threads:
- Terminal identity failure still blocks later WALLET backups (bitkit-ios#1121).
captureWalletBackupDataBytesrejects a null SDK snapshot whilehasIdentityForBackup()is true, and that failure is not treated as temporary. - Profile setup still re-reads a missing profile on resume, reconnect, and maintenance. The iOS twin at d83a217 skips that read while setup is pending.
- A rejected allowance admission still runs inside backup-state tracking, so a refusal before the SDK write clears the cached snapshot and schedules a WALLET upload.
Compared with bitkit-ios#1121 at d83a217: backup refusal and deferred profile refresh match. Android keeps the metadata-restore sign-in for the scheduled refresh; iOS clears in-memory authentication and initializes again. The setup-pending retry above is the functional drift.
Validation: source inspection of this pin and the iOS pin. Unit tests and journeys were not executed in this review. Device testing: not performed in this review.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — an off/on toggle can leave enabled allowances inactive, and the existing terminal WALLET backup failure still reproduces at 1b1426f.
Findings: One new MEDIUM; the existing HIGH remains unresolved.
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:814. With an unusable retained session and no recovery secret, a new ordinary watch-only reservation stayed pending. The real SDK rejected WALLET capture before the explicit completeness guard, leaving previous remote bytes/version unchanged. Original-credential recovery permitted complete upload. This adds current-head evidence to the existing report. - New MEDIUM — Restore allowance admission after an off/on toggle.
app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt:196. When allowances turn off and back on while activation is suspended,pause()revokes the captured revision and this return drops activation even though the setting is enabled again. The sequential contact/settings collector can conflate the updates back to its previous enabled snapshot, so no replacement activation runs. The repository remains inactive and the executor remains paused until another distinct state change or restart. A realAppViewModel/PaykitAllowanceRepointegration regression fails becauseexecutor.activate()is never called; the app also showedenabled=truewithactivation=nullafter releasing the hold. A later off/on cycle recovered it. Keep old queued acceptance revoked while ensuring the latest enabled identity receives a fresh activation.
Verified fixed at this head:
- Setup-pending profile retry: repeated reconnects started no profile read while setup was pending; clearing the flag allowed one successful read without replacing the sign-in.
- Rejected admission dirties backup state: rejection in the real SDK service preserved the backup version and identical cached snapshot.
QA for 1b1426f: Android 16 / API 36, two disposable unfunded staging/regtest wallets, real Paykit SDK and VSS operations. I built the unmodified head, then used unpushed Debug controls for timing holds, disposable credential/local-state staging and nonvisual assertions. Both installed controlled APKs matched their build; native libraries matched the unmodified APK. The controls do not replace native acceptance or backup decisions. Videos play at 2×; state, read counts, tokens and remote-backup comparisons were checked through Debug output and app logs.
Ordinary wallet backup after terminal identity failure ❌ New ordinary state stayed pending; actual VSS bytes/version were compared in Debug output.
With an invalid saved session and no recovery secret, a new ordinary watch-only account reservation remained pending: the real SDK rejected WALLET capture and prior remote bytes/version did not advance. Receiving still worked. Restoring original credentials enabled complete upload. Credentials, reservation and remote comparisons checked through Debug output and logs.
terminal-backup2x.2x.mp4
Ordinary wallet access during identity failure and credential recovery; backup assertions were checked separately.
Re-enabling allowances while activation is held ❌ The switch was on after release, but Debug state still showed no allowance activation.
While the real contact-sync activation was held after revision capture, the UI switched allowances off and back on. Releasing it left enabled=true with activation=null. A later separate off/on cycle restored activation. Settings/token state checked through Debug output and logs; focused real-collector integration also fails.
off-on-activation2x.2x.mp4
Actual settings toggles during held activation, followed by a separate recovery cycle; activation state was checked separately.
Reconnect skips profile reads while setup is pending ✅ Debug read counts stayed unchanged while setup was pending, then advanced once after it cleared.
With setup-pending true and no loaded own profile, nine reconnect calls started no profile reads and retained the sign-in. Clearing the flag allowed one real successful profile read; further calls after success performed none. Flag, read counts and sign-in state checked through Debug output and logs.
profile-pending2x.2x.mp4
Home during pending setup and subsequent profile recovery; flag and read counts were checked separately.
Rejected allowance acceptance preserves cached backup state ✅ Real SDK-service assertions confirmed that rejection preserved the cached snapshot and backup version.
Rejected admission in the real SDK service preserved both the backup version and the identical cached snapshot. The callback threw before native acceptance; nonvisual assertions checked through Debug output.
rejected-backup2x.2x.mp4
App display during the controlled rejection; backup cache/version assertions were checked separately.
Disabling allowances revokes delayed activation ✅ Debug state confirmed that disabling revoked the held activation and a fresh enable recovered it.
Allowance activation started by real contact synchronization held after revision capture was revoked by disabling allowances; after release it stayed inactive while off, and a fresh enable restored activation. Token and setting state checked through Debug output.
disabled-delayed-activation2x.2x.mp4
Disable during held activation and a later fresh enable; token state was checked separately.
Manual allowance acceptance across same-identity refresh ✅ The original UI acceptance survived an actual contact-key change; the native accepted state was checked in logs.
Tapped Accept on a real peer proposal while admission was held, added a different contact through the normal repository, and observed the real contact-sync activation preserve its token. The original UI acceptance then queued behind the SDK operation lock and changed the native proposal to ACCEPTED after release. State checked in Debug output and logs.
queued-manual2x.2x.mp4
Manual acceptance during real contact synchronization and the SDK lock hold; native state was checked separately.
Automatic offer acceptance across same-identity refresh ✅ The original automatic attempt accepted after same-identity refresh; token and native state were checked in logs.
The original automatic acceptance of a real peer offer queued behind the SDK operation lock. Same-identity reactivation retained the activation token, and releasing the lock let that original attempt change the native proposal to ACCEPTED. Tokens and native state checked in Debug output and app logs.
queued-offer-refresh2x.2x.mp4
App display during the controlled automatic acceptance queue; token and native state were checked separately.
Disabling allowances rejects queued offers ✅ Old queued attempts were refused, then a fresh attempt accepted; native states were checked in logs.
Old automatic acceptances stayed PROPOSED after disabling allowances and after disable/re-enable while queued behind the SDK lock. A separate fresh attempt using the new activation accepted the same offer. Old versus fresh attempts, token changes and native states checked in Debug output and app logs.
queued-offer2x.2x.mp4
App display during disable/re-enable and the fresh automatic attempt; admission and native state were checked separately.
profile reconnect without blocking maintenance ✅ Home recovered without Profile navigation; nonblocking calls, sign-in and address allocation were checked in Debug output.
A real own-profile read failed with device networking disabled. After reconnect, a successful response was held; overlapping recovery calls returned in under 2 ms and ordinary address allocation succeeded with the original sign-in retained. Releasing the response restored the Home name, and further calls started no read. Timing, sign-in, read counts and address state checked in Debug output and logs.
profile-reconnect2x.2x.mp4
Home during the profile outage and recovery; timing, sign-in and receiving were checked separately.
late retry after identity removal ✅ The late response was rejected after sign-out; identity and profile absence were checked in Debug output.
Held a real successful profile response started by reconnect recovery, completed normal sign-out, then released it. The existing stale-result guard rejected the response and identity, authentication and profile stayed absent. State and rejection checked through Debug output and logs.
profile-stale2x.2x.mp4
Home during sign-out and stale-response release; final state and rejection were checked separately.
Check: unmodified build and focused regressions ✅ The pinned head built and the selected suites passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting the profile, allowance, SDK-service, shared send-flow, backup and wallet-restore suites. Inspected JUnit XML and build diagnostics. The additional real-collector integration probe failed the expected activation assertion for the new MEDIUM above. The full compile/test/lint gate and Compose instrumentation were not rerun in this review. Output retained startup-provider, native-symbol stripping, JVM sharing and Gradle-deprecation notices.
The following passes remain at their original heads. Their exercised required-recovery, ordinary wallet, receiving, backup and identity-mutation paths and native/build pins are unchanged. The modified profile retry and allowance activation/admission paths were driven above. These are retained passes, not new executions at 1b1426f.
Restore and backup without a Pubky identity ✅ Retained pass at 78a0ff7.
Original device report. Real full VSS restore without Pubky identity completed after explicit Retry. Required markers cleared, new ordinary address excluded the old metadata receive address, and real WALLET/METADATA uploads synchronized with no SDK identity. UI shown in video; state, address and remote-backup assertions checked through Debug output.
Restart during optional initialization ✅ Retained pass at 78a0ff7.
Original device report. Killed the app during held scheduled initialization and held initialization again on relaunch. With credentials retained and identity/authentication absent, the ordinary node and new-address allocation worked. Releasing the hold recovered the saved profile and exactly one contact. State and count were checked through Debug output; no payment was submitted.
Connection correction survives required recovery ✅ Retained pass at 78a0ff7.
Original device report. An unreachable Electrum endpoint opened restricted connection recovery. Saved the reachable replacement; a failed RGS probe retained it. Required metadata held across three foreground cycles with one node rebuild in that process; restart retained spending/address gates. After release and Retry, old SETTINGS restore preserved the explicit Electrum correction, prior RGS URL and USD preference. Markers cleared and ordinary receiving succeeded. Endpoints and rebuild count were checked in app data/logs; video shows held recovery, restart and completion.
Ordinary backup after profile sign-out ✅ Retained pass at 78a0ff7.
Original device report. Normal repository sign-out cleared source, saved session and secret. New ordinary address allocation and real WALLET/METADATA backups synchronized with no SDK identity. Credentials, remote data and pending status were checked through Debug controls; video shows ordinary wallet access.
Ordinary backup after adopted identity unlink ✅ Retained pass at 78a0ff7.
Original device report. Bitkit discovered and adopted a real disposable identity from a same-signature Ring-contract provider through its choice UI, with an adopted source and no local secret. Normal Edit Profile unlink cleared source/session/secret while the provider still advertised that identity. New ordinary addresses and real WALLET/METADATA uploads synchronized without SDK identity. State and backup contents were checked in Debug output; Ring application UI was not tested.
Request payment without saved contacts ✅ Retained pass at 71fa609.
Original device report. With a real signed-in profile and no saved contacts, the Payments tab showed Request Payment and tapping it opened Contacts with the Add Contact sheet. The route was checked through the visible UI and semantic identifiers; fixture identity/count were checked separately.
Required wallet recovery blocks payment links ✅ Retained pass at 71fa609.
Original device report. Malformed input reached the real WALLET decoder. Restricted recovery blocked payment links, direct invoice admission and new addresses, including after process restart. Repair and Retry completed full restore and restored receiving. Decoder, admission and restart assertions were checked in app logs and Debug output.
Required USDT recovery blocks payment links ✅ Retained pass at 71fa609.
Original device report. The real retained-USDT decoder rejected malformed input and kept payment links, direct invoice admission and receiving gated, including after restart. Repair and Retry completed full restore and restored ordinary receiving. Decoder and admission were checked through Debug output and app logs.
Wallet access before final identity activation ✅ Retained pass at 71fa609.
Original device report. Required VSS recovery completed with final activation held, saved credentials retained and SDK/repository identity unavailable. Ordinary receiving worked and rejected WALLET capture preserved remote bytes/version plus pending work. After release, normal maintenance recovered the profile and exactly one contact after shared_state_busy retries. A later complete WALLET/METADATA upload synchronized once new address exclusions were acknowledged. Nonvisual state was checked in Debug output and logs.
No planned case remains unrun. Funded payment replay, nonempty USDT recovery and Ring application UI were not driven; the retained adopted-identity evidence uses a controlled Ring-contract provider.
Twin: synonymdev/bitkit-ios#1121, inspected at a6f233d, matches deferred initialization, setup-pending profile suppression and complete-backup protection. Android preserves an identity already activated during metadata restore. The new finding concerns Android's contact/settings activation collector; this was a targeted source comparison, with no iOS device run.
Review covered the contiguous 78269d8..1b1426f delta, all nine changed paths, open concerns and reached profile, activation, contact-sync, SDK and backup code, with an independent source review. The pinned head remained current at final recheck.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal WALLET backup failure still reproduces at b6751e2. The allowance off/on finding is fixed.
Findings: No new findings in this delta. One existing HIGH remains unresolved.
- Existing HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:814. With an unusable saved session and no recovery secret, a new ordinary watch-only reservation stayed pending. The real Paykit SDK rejected WALLET capture before the explicit completeness check; the previous remote bytes/version did not advance. Original-credential recovery enabled complete upload. This adds current-head evidence to the existing finding.
Verified off/on activation fixed: release now restores the enabled identity and Active row without restart, including delayed contact synchronization. Old queued allowance acceptance remains revoked; only a fresh attempt accepts.
QA for b6751e2: Android 16 / API 36, two disposable staging/regtest wallets; real Paykit SDK, VSS and Lightning operations. I built the unmodified head, then used unpushed Debug controls for timing holds, disposable credential staging, channel setup and nonvisual assertions. Installed Debug copies were verified against their builds; all native libraries matched the unmodified APK. Videos play at 2×. Token, journal, credential and remote-backup assertions below were checked through Debug output and app logs.
Ordinary wallet backup after terminal identity failure ❌ New ordinary state stayed pending; actual VSS bytes/version and pending status were compared through Debug controls.
With an invalid saved session and no recovery secret, a new ordinary watch-only reservation stayed pending: the real Paykit SDK rejected WALLET capture before the completeness guard, and remote WALLET bytes/version stayed unchanged. Ordinary address allocation succeeded. Restoring the original credentials permitted complete upload. Reservation, credentials, native error, remote comparisons and pending state checked in Debug output/logs.
terminal-backup2x.2x.mp4
Ordinary Home during terminal identity loss; address allocation, credential recovery and backup comparisons were checked separately.
Re-enabling allowances while activation is held ✅ Debug state confirmed fresh activation after release, without another toggle or restart.
Held the real contact-sync activation after revision capture, toggled the UI off/on, then released it. The latest enabled identity received a fresh activation without a restart or another settings cycle. Debug token and setting state plus app logs establish activation.
off-on-activation2x.2x.mp4
Actual off/on switches during held activation; activation state was checked separately.
Disabled allowances show an ordinary request and restore the active row ✅ The request stayed manual while off; normal and delayed-contact re-enable restored the Active row.
With usable real regtest Lightning channels and a real Active grant covering 2500 sats, disabling hid the tab and opened the peer request with Swipe To Pay. Automatic journal state did not advance while off. Re-enable restored Active; the first request then paid once. A separate contact-sync hold plus off/on recovered fresh activation and the Active row in the same PID. UI, native channels/journal and logs checked.
| Allowances off | Re-enable | Delayed contact synchronization |
|---|---|---|
j2-off-pair2x.2x.mp4 |
j2-resume2x.2x.mp4 |
j2-delayed-contacts2x.2x.mp4 |
Payer left and requester right in the first video. The other clips show the restored Active row; journal and same-process assertions were checked separately. The first request paid once after re-enable, confirmed by the automatic journal, native payment-success event and recipient receipt.
Disabling allowances revokes delayed activation ✅ Debug state confirmed stale activation stayed revoked while off and a fresh enable recovered it.
A real contact-sync activation was held after revision capture. Disabling revoked it; after release it stayed inactive while off. A fresh enable activated it. Settings and activation were checked in Debug output and app logs.
disabled-delayed-activation2x.2x.mp4
Disable during held activation, then a separate fresh enable; activation state was checked separately.
Manual allowance acceptance across same-identity refresh ✅ The original UI acceptance reached native ACCEPTED after real contact synchronization; state was checked in logs.
Tapped Accept on a real peer proposal while SDK admission was held. Adding a different real contact triggered normal same-identity synchronization without changing its activation token. The original attempt queued behind the SDK lock and changed the native proposal to ACCEPTED after release. Tokens and native state were checked in Debug output/logs.
queued-manual2x.2x.mp4
Manual acceptance during actual contact synchronization and the SDK lock hold; native outcome was checked separately.
Automatic offer acceptance across same-identity refresh ✅ The original automatic attempt reached native ACCEPTED; token and proposal state were checked in logs.
The original automatic acceptance of a real peer offer waited behind the SDK operation lock. Same-identity reactivation retained its token; release let that original attempt move the native proposal to ACCEPTED. Debug output and logs establish token/native state; the video shows the app during the hold.
queued-offer-refresh2x.2x.mp4
App display during the automatic acceptance queue; token and native outcome were checked separately.
Disabling allowances rejects queued offers ✅ Old attempts stayed PROPOSED across disable/re-enable; only a fresh attempt accepted, checked in logs.
Old automatic attempts stayed PROPOSED after disabling and after disable/re-enable while queued behind the SDK lock. After lock release, real contact synchronization installed a fresh activation without a direct activation call. The old attempt still stayed PROPOSED; releasing a separate fresh-attempt gate accepted the native offer. Tokens, proposal state and gate ordering were checked in Debug output/logs.
queued-offer2x.2x.mp4
App display during disable/re-enable and fresh admission; old/fresh native outcomes were checked separately.
Check: build and focused regressions ✅ The unmodified app built, the allowance regressions passed, and routing diagnostics passed locally.
Ran assembleDevDebug and the selected AppViewModelSendFlowTest and PaykitAllowanceRepoTest suites on unmodified source, including the new real-collector off/on regression. Ran PubkyAuthManifestTest explicitly after correcting its test-source transfer, with Debug timing controls present. JUnit XML and build diagnostics were inspected. The full local compile/test/lint gate and Compose instrumentation were not rerun in this review. Earlier hosted routing failure causation was not established by this local pass.
The following passes retain their original heads and reports. Their exercised restore, ordinary-wallet, profile, identity and backup paths, native dependencies and build pins are untouched by this delta; these are retained passes, not new executions at b6751e2.
Restore and backup without a Pubky identity ✅ Retained pass at 78a0ff7.
Original device report. Real full VSS restore, new ordinary addresses and WALLET/METADATA uploads succeeded without an SDK identity. Nonvisual assertions retain that report's Debug-control limits.
Restart during optional initialization ✅ Retained pass at 78a0ff7.
Original device report. Restart during held optional initialization retained credentials and ordinary receiving, then recovered the same identity and one contact. No payment was submitted. Nonvisual assertions retain that report's Debug-control limits.
Connection correction survives required recovery ✅ Retained pass at 78a0ff7.
Original device report. Restricted recovery survived foreground cycles and restart. The explicit Electrum correction survived SETTINGS restore; Retry restored ordinary receiving. Nonvisual assertions retain that report's Debug-control limits.
Ordinary backup after profile sign-out ✅ Retained pass at 78a0ff7.
Original device report. Normal profile sign-out cleared credentials; ordinary receiving and real WALLET/METADATA backups continued without an SDK identity. Nonvisual assertions retain that report's Debug-control limits.
Ordinary backup after adopted identity unlink ✅ Retained pass at 78a0ff7.
Original device report. Normal unlink after adoption through a controlled Ring-contract provider cleared credentials; ordinary receiving and backups continued. Actual Ring application UI was not tested. Nonvisual assertions retain that report's Debug-control limits.
Request payment without saved contacts ✅ Retained pass at 71fa609.
Original device report. With a signed-in profile and no saved contacts, Request Payment opened Contacts and its Add Contact sheet. Nonvisual assertions retain that report's Debug-control limits.
Required wallet recovery blocks payment links ✅ Retained pass at 71fa609.
Original device report. Actual WALLET decoder failure and restart kept payment links, direct admission and new addresses gated. Repair and Retry finished full restore. Nonvisual assertions retain that report's Debug-control limits.
Required USDT recovery blocks payment links ✅ Retained pass at 71fa609.
Original device report. Actual retained-USDT decoder failure and restart kept payment/address admission gated. Repair and Retry restored receiving; this did not exercise nonempty USDT recovery. Nonvisual assertions retain that report's Debug-control limits.
Wallet access before final identity activation ✅ Retained pass at 71fa609.
Original device report. Required VSS recovery completed with final identity activation held. Receiving worked, incomplete capture preserved the previous WALLET, and release recovered the profile, one contact and complete upload. Nonvisual assertions retain that report's Debug-control limits.
Rejected allowance acceptance preserves cached backup state ✅ Retained pass at 1b1426f.
Original device report. Rejected admission in the real Paykit SDK service preserved both the backup version and the identical cached snapshot. Nonvisual assertions retain that report's Debug-control limits.
Reconnect skips profile reads while setup is pending ✅ Retained pass at 1b1426f.
Original device report. Reconnects made no own-profile read while setup was pending. Clearing the flag allowed one successful read without replacing the sign-in. Nonvisual assertions retain that report's Debug-control limits.
Profile reconnect without blocking maintenance ✅ Retained pass at 1b1426f.
Original device report. After a real offline profile failure, a held retry did not block maintenance or address allocation. Home recovered without opening Profile; success stopped further reads. Nonvisual assertions retain that report's Debug-control limits.
Late retry after identity removal ✅ Retained pass at 1b1426f.
Original device report. A held real reconnect response released after normal sign-out was rejected; identity, authentication and profile stayed absent. Nonvisual assertions retain that report's Debug-control limits.
No planned case remains unrun. Funded payment replay across process death, nonempty USDT recovery and actual Ring application UI were not driven; the adopted-identity evidence uses a controlled Ring-contract provider.
Twin: synonymdev/bitkit-ios#1121 fix: decouple wallet restore from profile refresh, inspected at a6f233d, matches deferred restore, missing-profile recovery and complete-backup protection. Android’s pause revision addresses its sequential contact collector; iOS uses a separate settings-change activation path. Source comparison only; no iOS device run.
Review covered the contiguous 1b1426f..b6751e2 delta, all five changed paths, the open backup finding and reached contact, allowance, SDK and backup code, with independent source review. The pinned head remained current at final recheck.
Reviewed by gpt-6.1-sol-high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up at b6751e2, covering the delta since 78269d8 (baseline review). Base and merge-base remain 1337f121. Inherited coverage is scheduled restore coalescing and the same-key import skip, WALLET/METADATA backup refusal, restore completion before identity setup, restore-during-identity-outage and connection-recovery, and the iOS twin behavior reviewed at d83a217. This pass rechecked profile-setup retry, the allowance activation token, and backup admission placement.
No new actionable code findings.
Reconnect while a sign-in is already active skips the missing-profile read for as long as profile setup is pending, and performs that read after the flag is cleared. Pause and deactivation invalidate an activation that is only queued or waiting in caller admission. Turning allowances off and back on advances the contact-sync pause revision, so the latest enabled identity activates again and the stale callback stays revoked. Allowance admission runs before backup-state tracking, so a refusal leaves the cached snapshot and version in place. Those two contracts match bitkit-ios#1121 at a6f233d. Commits after d83a217 on that pin change channel-close refresh only. The Android threads for setup-pending retry, rejected admission, and off/on resume are resolved.
The existing terminal-identity failure still matches this pin: captureWalletBackupDataBytes rejects a null SDK snapshot while hasIdentityForBackup() is true, and that failure is not classified as temporary (discussion). BackupRepo.kt is unchanged since the baseline. Ovi's review on this head reports that the same failure still reproduces; this review did not repeat that device run.
Validation: source inspection of the pinned Android head and the iOS comparison pin. Unit tests and journeys were not executed in this review. Device testing: not performed in this review. The open WALLET backup failure remains the outstanding concern.
Reviewer model: grok-4.7
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.
| the Overview and Payments tabs, and a request covered by an Active allowance is not paid | ||
| automatically: it opens as an ordinary Payment Request. Turning them back on shows the tab again. | ||
| automatically: it opens as an ordinary Payment Request. Turning them back on restores both the tab | ||
| and its Active allowance row. On Android, repeat the off/on sequence while contact loading is |
There was a problem hiding this comment.
LOW: the new description promises a check that none of the actions perform. "On Android, repeat the off/on sequence while contact loading is delayed, then let it finish: the latest enabled state must restore the row without restarting the app." The <actions> are unchanged: one off, one request, one on, then verify the Active row. No action delays contact loading, repeats the toggle, or checks that the row returns without a restart. A device reviewer following this journey therefore never exercises 181d664. The Capabilities table offers no way to hold contact loading either.
Fix: add explicit actions, for example:
- airplane mode on after the first off;
- toggle off and on twice;
- airplane mode off;
- verify the Active row and the Allowances tab without relaunching.
Or move the sentence out of the journey into a manual-test note. The file name and prose are meant to stay identical with the iOS twin, so an "On Android" step needs recording in the cross-platform table either way.
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.
Current-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.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
N/A — no layout change. After a transient own-profile read failure, existing Home/profile presentation can recover through normal maintenance without opening Profile or re-importing the session.
QA Notes
Setup
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
J2 —
allowances/allowances-turned-off.xml: the existing off/on journey includes the delayed contact-loading variant; restore the Active row without restarting. This additional boundary 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
Planning/implementation: gpt-6-astra (xhigh). Independent source/integration/diagnostic reviews: gpt-6-astra (xhigh) and gpt-6.1-sol (high), with actual execution metadata retained.