Skip to content

fix: decouple wallet restore from profile refresh - #1686

Open
ben-kaufman wants to merge 10 commits into
masterfrom
fix/restore-identity-background
Open

ben-kaufman wants to merge 10 commits into
masterfrom
fix/restore-identity-background

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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 9ac9746ae retries 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 tree and validation

Head 217b42ef889952a6c1f3a30b0c44a8b1001db4a5 includes a signed additive merge of live master e28307b9d5b8dc7e1e47af04adae32567353b1d0. Final tree: 332faa4c9a315d61507466b052cc7ddd76ae50f7. The BackupRepoTest conflict retains both the four complete-backup tests and master's main-thread responsiveness test; the owned product delta is preserved.

  • Final just format, just compile, just test and just lint passed: 4,709 tests / 256 suites, zero failures, errors or skips; Detekt and Android Lint contain zero findings.
  • All 1,764 frozen source/build inputs match. Complete 9,770-line test log, compile/lint logs, XML, SARIF and embedded Gradle diagnostics were inspected. Three JVM notices and four cursor CloseGuard warnings match verified base/test sources. An older copied Gradle problem report is retained as historical evidence, not a finding from this run.
  • Independent composition review is clean. All 10 commits in the actual live-base range verify locally and on GitHub (verified=true, reason=valid).
  • Current-head CI passed 4,709 units in 256 suites, zero failures/skips; lint has zero Detekt/Android Lint findings. E2E passed all 39 cases on their first attempts with one explicit settings skip. All 14 completed job logs, 17 notices and four SHA-256/CRC-verified artifacts were inspected; actual synthetic merge 0f7270ee equals this head tree and uses current base e28307b9. The provider/JVM notices are verified unchanged; hosted Gradle problem HTML was not uploaded, so its details remain unavailable. Migration skipped, and stock E2E does not establish J1 or close the terminal WALLET HIGH finding. No CI rerun was requested.
  • The terminal full-WALLET availability finding and authenticated J1 acceptance remain open; conflict resolution and unit success do not close them.

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.

Journeys

  • 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 at 0b77f099; its iOS evidence does not establish Android acceptance.

Evidence and remaining gaps

PubkyRepoTest covers the new retry and retained coalescing, cancellation, sign-in and profile-write guards. Existing WalletViewModelTest and BackupRepoTest cover 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.

Previous revision evidence

At 9ce430274e

Head 9ce430274e9657c06bb11e8e91f420cccf6fcb99 includes signed additive composition with live master 0924ae5d6be2dcc0faa932ac2b9e11de045bd98b. Final tree: 3a9ac06d8eb8994078a6a95aa74066935d10f8c0.

  • Final just format changed no inputs; just compile, just test and just lint passed. 4,681 tests / 256 suites, zero failures, errors or skips; Detekt and Android Lint SARIF/XML contain zero findings.
  • All 1,759 frozen source/build hashes match. Complete 9,623-line test log, compile/lint logs, XML, SARIF and embedded Gradle diagnostics were inspected. Three JVM bootstrap notices, four cursor CloseGuard stacks from unchanged provider/test sources, and the pinned Detekt plugin deprecation remain documented against actual-base bytes.
  • Two corrected regression tests fail when only the new retry branch is disabled, then pass with it. They cover a held read that does not delay maintenance, no session re-import, overlapping retries, success stopping retries, and rejection after the original identity ends. Earlier test-dispatcher scheduling failures are retained; they are not product-failure evidence.
  • A fresh independent combined review and affected composition/test rechecks are clean under the current risk-based review policy. All eight commits in the live-base range verify locally and on GitHub.
  • Revision-scoped CI and lint pass: 4,681 tests / 256 suites, zero failures/errors/skips and zero lint findings. Complete 11,113 log lines, 258 HTML reports and XML/SARIF were inspected. Synthetic merge 0f1a4288 has the exact head tree.
  • Revision-scoped E2E completes with 39 eventual passes / one explicit settings skip. Six shards passed first attempt. The LNURL/transfer shard had two first-attempt 30-second text-appearance failures (150 and Transfer) and passed both tests on its automatic second attempt. All 11 logs / 7,089 lines and failure annotations were inspected; the first raw exit 1 is retained despite the step's success conclusion. No rerun was requested.
  • The harness is 4c0cbef0. First-attempt screenshot/video/app-log paths appear in the log, but those files were not uploaded after eventual success; cause remains unproven. E2E APK digest matches across hosted upload and seven downloads; no local installed-binary claim. A separate optional CI APK download timed out twice and is preserved without further unchanged retry. Migration was skipped, not passed.

At 89e2702e, CI, lint and E2E passed on the exact published tree: 4,607 units, zero lint findings, and 39 first-attempt E2E passes / one explicit settings skip. All 14 logs and four digest/CRC-verified archives were inspected. Earlier manifest, invoice and timeout attempts remain revision-scoped and unwaived; no unchanged rerun or causal-fix claim.

Models used

  • Planning/scoping: gpt-6-astra (reasoning: xhigh).
  • Implementation: gpt-6-astra (reasoning: xhigh).
  • Review: gpt-6-astra (reasoning: xhigh), gpt-6.1-sol (reasoning: high). Historical pairs are preserved; the focused follow-up reviewer and current author execution were verified against their run metadata.

@ovitrif ovitrif added this to the 3.1.0 milestone Oct 24, 2026
@ovitrif

ovitrif commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

Triage: 3.1.0. It keeps the shipped wallet restore from waiting on profile refresh, and 3.1.0 is due 28 Oct.

@greptile-apps

greptile-apps Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

Wallet restore now schedules optional identity activation and profile reads without waiting for them.

  • Wallet restore finishes while Pubky identity refresh runs in the background.
  • Backups stop before upload when saved identity data is incomplete.

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]
Loading

Reviews (1) · Last reviewed commit: "fix: decouple wallet restore from profil..." · Reviewed by Greptile

@talosmachina talosmachina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-preserving clearAuthenticatedState, both of which are a newer identity's own path, so dropping the stale job is the intent. restoreSessionBackupState bumps it inside the restore, before the schedule reads it.
  • Something after Completed needing the identity: the public key is already set inside the required boundary (restoreSessionBackupState LocalSeed startSignIn), and the payment-request activate at BackupRepo.kt:1058 runs 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: initializeSession catches its own failures, checkAdoptedSource returns a Result, and appScope carries a SupervisorJob plus a logging handler.
  • WALLET backup blocked for good while a saved session exists: with a stored session currentPublicKey() reads the bound identity state through identity_status, so state is null only when the SDK has no identity, which is the case the check means to hold; an unreadable keychain fails the backup (covered by wallet backup cannot treat unreadable identity as absent).
  • METADATA getOrThrow turning a benign miss into a failure: both snapshots return null for absence inside runSuspendCatching, 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

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 217b42e (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 for per 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.

Comment thread app/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: ⚠️ Not verified — full J1 remains partial; the acknowledged backup-availability issue is still open.

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.WalletViewModelTest

I 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 hook

Base-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 hook

Coverage: 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 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jvsena42

Copy link
Copy Markdown
Member

Device gate (partial): 0fd5e42 — Pixel emulator through a local homeserver fault proxy, base = merge-base 55b31ee (8d72053 on the base branch isn't in this head). LocalSeed backup: profile created in Bitkit on staging, two contacts.

Full restore success → Completed (Get Started shown; screen polling, ±2 s):

Homeserver Head Base
Reachable 2.12 s 12.75 s
Blocked, held 2.14 s 103.65 s
Blocked, blackholed 2.00 s 192.38 s
  • Base under the outage waits on re-sign-in: Failed to restore paykit session, attempting re-sign-in (10:33:09.082 / 10:50:38.611 UTC), then Failed re-sign-in recovery … complete Pubky auth flow (10:33:40.664 / 10:52:49.102).
  • Restored paykit session for: 0 per restore on head, as expected for LocalSeed (one on the next cold start); base 1 when reachable, and 1 after release from its deferred retry.
  • Head keeps the identity through the outage and lists the contacts; base has no identity and shows the Contacts intro.
  • WALLET backup on head succeeded once the homeserver was reachable. After release it took 5 Retrying backup for: 'WALLET' … acquire Pubky write lock (10:41:39–10:42:23) before succeeding at 10:43:05.858, 92 s after release; base needed one retry, 23 s after release. Not yet shown to come from this PR.

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 (Failed to load profile [… fetch profile] about 60 s in), until Profile was opened or the app restarted; base reloaded identity, profile and contacts 18 s after release. Seen in one outage run so far. It needs a re-run and a code trace before I raise it. The iOS twin reloaded the profile about 97 s after release.

⚠️ Not run: restore-during-identity-outage.xml steps 2 and 6 (LocalSeed activates the identity inside the metadata restore, before the fault can start; VSS shares a staging host the proxy can't isolate) and step 4 (time). Step 1 partial (wallet access in 2 s), step 3 passes, step 5 only after release. An ExternalSession backup wasn't tested.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Completed without waiting for optional identity setup. That setup is only scheduled after performFullRestoreFromLatestBackup succeeds, 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 new PubkyRepoTest regression 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-time pubkyRepo.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 pubkySession is 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
jvsena42 previously approved these changes Oct 27, 2026

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

⚠️ Not run: 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.

Comment thread app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@jvsena42

jvsena42 commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

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 libldk_node.so (A Tokio 1.x context was found, but it is being shutdown.), 2–4 ms after a failed //node_metrics write. It happens when the node stops on backgrounding while VSS is unreachable and a backup is pending. It first showed up during the gate on #1689, which is stacked on this branch.

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. libldk_node.so is byte-identical in both builds (BuildId 7224179a…).

Build With a backup pending Idle wallet
master ea89ed1 1 of 4 crashed (MB8, today 07:34) 0 of 3
this PR 0fd5e42 2 of 4 crashed 0 of 3

Master's crash has the same abort message and the same 39-frame backtrace; only one frame offset differs:

2026-10-28 07:34:18.028 ERROR [ldk_node::io::utils:311] Writing data to key //node_metrics failed due to: I/O Error: Other. "Failed to write to key //node_metrics: InternalError: error sending request for url (https://bitkit.stag0.blocktank.to/vss…
10-28 08:34:18.030 F libc    : Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 19688 (Thread-276), pid 19424 (to.bitkit.dev)
10-28 08:34:18.312 F DEBUG   : Abort message: 'A Tokio 1.x context was found, but it is being shutdown.'

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.

Copy link
Copy Markdown
Contributor Author

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 piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@ovitrif
ovitrif changed the base branch from fix/pending-wallet-restore-admission to master October 28, 2026 18:49
@ovitrif
ovitrif dismissed jvsena42’s stale review October 28, 2026 18:49

The base branch was changed.

@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Retargeted this PR to master before merging its base, #1685, so deleting that branch would not close it. If the base change dismissed the approval, it needs a re-approval.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: ⛔️ Request changes — I reproduced both existing failures at 89e2702: terminal identity failure prevents subsequent complete WALLET backup, and optional profile reads do not retry after connectivity returns.

Findings: No new findings in this delta. The existing reports remain open:

  • HIGH — terminal identity failure leaves newer ordinary WALLET state unbacked up. app/src/main/java/to/bitkit/repositories/BackupRepo.kt:784–785. With an unusable saved session and no recovery secret, I added an ordinary watch-only account reservation and attempted backup. The Paykit SDK snapshot failed with an identity error, before the explicit completeness guard. The previous complete remote WALLET remained intact, but its bytes/version did not advance and newer work stayed pending. This adds current-head evidence to the existing terminal-backup report, whose Android impact is already acknowledged there; it is not a duplicate finding. Restoring the original credentials allowed upload again.
  • LOW — the own profile remains absent after a transient read failure. app/src/main/java/to/bitkit/repositories/PubkyRepo.kt:382. After reconnect, WALLET and METADATA uploads succeeded, but the active identity still had profile=null and Home showed “Your Name” 111 seconds later. Opening Profile loaded the saved name immediately. This reproduces the existing profile-retry report.

QA for 89e2702: Android 16 / API 36, Pixel 7 emulator; disposable unfunded staging/regtest wallet with real VSS and Paykit SDK operations. I built the unmodified head, then used local Debug controls to hold asynchronous work, stage invalid recovery inputs and inspect repository/remote backup state. The controlled APK used the same native libraries as the unmodified build. Each case below ran at this head; nonvisual assertions are identified explicitly. Videos play at 2×.

Test 1: restore without a Pubky identity ✅ Restore, receiving and complete backups succeeded; identity/backup state was checked through Debug controls.

Restored the real no-identity VSS backup, reached the restored screen and allocated an ordinary address. WALLET and METADATA synchronized while the Paykit SDK had no identity.

Q1-no-identity2x.2x.mp4

Restore completion and ordinary wallet access.

Test 2: protect complete WALLET during identity loss ✅ Debug comparisons confirmed unchanged remote bytes/version and pending work; credential recovery restored complete upload.

Removed access to the real Paykit SDK identity, attempted WALLET backup, then signed in with the original disposable secret. The failed attempt preserved the previous complete backup; the recovered identity permitted a fresh snapshot and upload.

J1-backup-final2x.2x.mp4

Wallet UI during the controlled loss/recovery; remote contents and pending-state assertions were checked separately.

Test 3: restart during optional initialization ✅ Ordinary receiving remained available while initialization was held; Debug state confirmed recovery of the same identity and one contact.

Killed the app during scheduled work and held initialization on relaunch. Allocated a new address before release, then recovered the saved identity without duplicating the contact. No payment was submitted.

J1-restart2x.2x.mp4

Relaunch, ordinary wallet access and contact recovery.

Test 4: required WALLET recovery failure ✅ The error offered no Proceed action, and Debug calls confirmed payment/address admission stayed blocked.

A controlled malformed payload failed the real WALLET decoder while retaining the original backup. Repeated recovery retained the error; invoice admission and address allocation returned WalletBackupRestorePendingError. Repair and successful retry are covered by Test 6.

J1-required-wallet2x.2x.mp4

Required recovery remains on the failure screen.

Test 5: required USDT recovery failure ✅ The real decoder failure retained the recovery screen; Debug calls confirmed payment/address admission stayed blocked.

Staged an invalid retained USDT payload before metadata/WALLET recovery. There was no Proceed action. Restored the original payload for the subsequent successful retry.

J1-required-usdt2x.2x.mp4

USDT failure keeps required recovery incomplete.

Test 6: wallet access before final identity activation ✅ Required recovery completed and receiving worked while Debug state confirmed both repository authentication and Paykit SDK identity were unavailable.

After real required VSS imports, held scheduled activation and suspended Paykit platform-session access while preserving saved credentials. Restore completed and both recovery markers cleared. During the hold, a WALLET attempt preserved prior remote bytes/version and pending work. Releasing activation recovered the same identity/contact and a fresh complete upload. Authentication, markers and remote backup comparisons were checked through Debug controls.

J1-activation-final2x.2x.mp4

Restored wallet and Receive remain usable during held activation.

Test 7: connection correction and required recovery ✅ The saved correction survived restart and old SETTINGS restore; app data and logs confirmed retained preferences, admission gates and one node restart in the foreground-cycle process.

An unreachable Electrum endpoint opened restricted connection recovery. Saved a reachable replacement; a separate failed RGS probe preserved that correction. Held required metadata across three foreground cycles, restarted the app and retried recovery. The explicit Electrum correction survived the older SETTINGS backup; RGS/USD preferences stayed intact. Both recovery markers cleared and a new address succeeded. Endpoint persistence, restart count and operation gates were checked separately from the recordings.

Connection correction Restart and completed recovery
J1-connection-final2x.2x.mp4
J1-connection-final-resume2x.2x.mp4

Save a correction, then retry the retained required recovery.

Test 8: delayed profile response after sign-out ✅ The normal stale-result guard rejected the response; Debug state and UI confirmed authentication/profile remained empty.

Held a real profile response, signed out through the repository and released the response. The guard reported current=false; Contacts showed its intro.

J1-stale2x.2x.mp4

Removed identity stays removed when the delayed response returns.

Test 9: ordinary backup after profile sign-out ✅ Debug controls confirmed cleared credentials, successful address allocation and synchronized WALLET/METADATA without a Paykit SDK identity.

Normal sign-out cleared the source marker, local secret and saved session. Ordinary wallet changes remained eligible for complete backup.

Q2-signout2x.2x.mp4

Data Backups after profile sign-out.

Test 10: terminal identity failure and later WALLET changes ❌ Debug remote comparisons confirmed newer ordinary state remained pending after the real Paykit SDK rejected backup.

Invalidated the saved session with no recovery secret. The real resolver reached its terminal fallback. Reserved watch-only account index 1 through WatchOnlyAccountStore, then attempted WALLET backup: identity error, unchanged prior remote bytes/version and pending newer work. This attempt failed at the snapshot call before the explicit completeness guard. Restoring the original credentials later enabled upload. This is the existing HIGH report above.

J1-terminal-final2x.2x.mp4

Ordinary wallet remains accessible during the terminal condition; the backup failure and remote comparisons were checked through Debug output.

Test 11: ordinary backup after adopted-identity unlink ✅ Normal Bitkit unlink cleared adopted credentials; Debug controls confirmed a new address and synchronized WALLET/METADATA.

Used a same-signature provider implementing Ring’s Android identity/credential contract, holding a real disposable identity. Bitkit’s normal choice UI discovered and adopted it through Binder and the Paykit SDK, with an adopted source and no local secret. Normal Edit Profile unlink cleared source/session/secret while the provider still advertised the identity. The provider was a controlled stand-in; this does not verify Ring application UI.

Adopt and unlink Backups after unlink
Q3-ring2x.2x.mp4
Q3-ring-backup2x.2x.mp4

Normal Bitkit adoption/unlink and subsequent backup synchronization.

Test 12: profile retry after optional-read outage ❌ After reconnect, Home retained the placeholder; Debug state still showed an authenticated identity with no profile 111 seconds later.

Restaged LocalSeed identity metadata with optional enrichment held, disconnected only the emulator and released enrichment. The actual profile fetch failed with transport_error. Reconnected: real WALLET/METADATA uploads succeeded, but the own profile stayed absent. Opening Profile then loaded “QA restore” immediately, confirmed in repository state and the profile layout. The recording shows the stale Home/Receive UI; outage, successful uploads, timing and manual recovery were checked separately. This is the existing LOW report above.

Q4-profile-recovery2x.2x.mp4

Home still shows “Your Name” despite recovered connectivity and working ordinary receiving.

Check 1: unmodified build and focused regressions ✅ The pinned head built and the selected repository/ViewModel suites passed.

Ran assembleDevDebug and testDevDebugUnitTest filtered to PubkyRepoTest, BackupRepoTest and WalletViewModelTest. Inspected the build output and JUnit results.

Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at c0b1e86 matches deferred initialization, revision protection and complete-backup protection. Android intentionally retains an already-active restored identity.

Coverage: contiguous delta 0fd5e42..89e2702 plus open concerns and reached recovery/identity code, with an independent review. I re-ran the native-path cases from the previous review after dependency changes; no prior pass substitutes for a case above. The matching-head source/CI report provides additional context. No planned case is unrun. Funded payment replay/transfers, Ring application UI and unrelated inherited payment/UI/translation changes received no device pass here. The target is now master; this tests the unchanged PR head, not a newly composed merge with the latest master.

Reviewer and independent reviewer: gpt-6.1-sol, reasoning effort high.


Reviewed by gpt-6.1-sol-high

@ben-kaufman
ben-kaufman requested a review from jvsena42 October 28, 2026 22:15

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: ⛔️ Request changes — the existing terminal WALLET backup failure still reproduces at 9ce4302.

Findings: No new findings in 89e2702..9ce4302.

  • 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 returned identity_error before the completeness guard, and remote WALLET bytes/version did not advance. Restoring the original credentials allowed complete upload again. This confirms the open report above; it is not a duplicate finding.

The missing-profile retry LOW is fixed: Home recovered after reconnect without opening Profile, and the held retry did not delay maintenance or re-import the active session.

QA for 9ce4302: Android 16 / API 36, Pixel 7 emulator; disposable unfunded staging/regtest wallet, real Paykit SDK and VSS operations. I built the unmodified head, then drove it with local Debug controls for holds, recovery inputs and state inspection. Installed APK hashes matched their builds; native libraries matched the unmodified APK. Nonvisual assertions below were checked through those controls and app logs. Videos play at 2×.

Test 1: J1 — missing-profile reconnect ✅ Home recovered automatically; Debug controls confirmed nonblocking, coalesced reads and retained sign-in.

The real own-profile read failed while offline. On reconnect, one successful response was held; three overlapping retry calls returned in 6 ms, connectivity maintenance continued, and an ordinary address was allocated. Receive remained usable. After release, Home showed “QA restore” without Profile navigation. Further retry calls started no new read, and no session initialization/import occurred during this sequence.

profile-retry-final2x.2x.mp4

Home before recovery, ordinary receiving, then the restored name. Read counts, timing and sign-in preservation were checked separately.

Test 2: J1 — late retry after sign-out ✅ The normal stale-result guard rejected the response; Debug state remained signed out.

Held a real successful profile response started by restoreSessionIfNeeded(), completed normal sign-out, then released it. The guard rejected the old response; credentials, authentication and profile stayed absent. Contacts showed its intro.

retry-stale-final2x.2x.mp4

Removed identity remains removed after the delayed response returns; rejection was confirmed in app logs.

Test 3: terminal identity failure and ordinary WALLET changes ❌ Debug remote comparisons confirmed newer ordinary state stayed pending.

Invalidated the saved session and removed access to its recovery secret. The real resolver reached terminal failure. Reserved watch-only account index 1, then attempted WALLET backup: SDK identity error, unchanged previous remote bytes/version, and pending newer work. Ordinary address allocation still succeeded. Restoring the original credentials subsequently restored complete upload. This is the existing HIGH above.

terminal-backup2x.2x.mp4

Ordinary wallet access during terminal identity failure; reservation and remote-backup assertions were checked through Debug controls.

Test 4: J1 — required WALLET recovery failure ✅ The error retained payment/address gates, confirmed through Debug calls.

Malformed input failed the real required WALLET decoder while preserving the original backup. Retry retained the error screen with no Proceed action. Invoice admission and address allocation returned WalletBackupRestorePendingError. The original input was repaired for Test 6.

required-wallet2x.2x.mp4

Required recovery remains on the failure screen; operation admission was checked separately.

Test 5: J1 — required USDT recovery failure ✅ The real decoder failure retained recovery and operation gates.

An invalid retained USDT payload failed in restoreUsdtRecovery(). The error offered no Proceed action; Debug calls confirmed payment and address admission remained blocked. The original payload was retained for repair.

required-usdt2x.2x.mp4

USDT recovery failure keeps the required restore incomplete; decoder and admission results were checked in app logs.

Test 6: J1 — wallet access before final identity activation ✅ Required restore completed before authentication; Debug checks confirmed backup protection and recovery.

Repaired the original WALLET/USDT inputs and completed real required VSS imports. Held final activation with saved credentials retained: both required markers cleared, the node ran, and SDK/repository identity remained unavailable. Left the restored screen and opened Receive; new-address allocation succeeded. A complete-backup attempt preserved prior remote bytes/version and pending work.

After release, identity/profile and one contact recovered, then a fresh complete WALLET/METADATA upload succeeded. Authentication, markers and remote comparisons were checked through Debug controls.

activation2x.2x.mp4

Restored wallet and Receive remain usable during held activation, followed by profile recovery.

Check 1: unmodified build and focused regressions ✅ The pinned head built and the selected suites passed.

Ran assembleDevDebug and testDevDebugUnitTest filtered to PubkyRepoTest, BackupRepoTest and WalletViewModelTest; inspected the build log and JUnit results. Changed journey XML and diff whitespace checks passed.

Debug controls used for the device checks

Not pushed. These controls hold real responses and activation, stage recovery inputs, and compare actual SDK/VSS state. They do not mock SDK results or change backup/admission decisions. For setup, the real native database was initialized after application storage became available.

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 371fbf34f..9703f8347 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -872,6 +872,12 @@ class BackupRepo @Inject constructor(
                 }
                 connectionSession.replay()
                 restoreUsdtRecovery()
+                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)
+                    Logger.info("QA1686 required metadata download released", context = TAG)
+                }
                 performRestore(BackupCategory.METADATA) { dataBytes ->
                     val restored = restoreMetadataBackup(dataBytes, onCacheRestored)
                     if (restored.needsRewrite) categoriesNeedingRewrite += BackupCategory.METADATA
@@ -1037,7 +1043,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/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index e39959f5e..edc4869c5 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -1065,6 +1065,7 @@ class AppViewModel @Inject constructor(
                 .filter { it == ConnectivityState.CONNECTED }
                 .collect {
                     pubkyRepo.restoreSessionIfNeeded()
+                    if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 connectivity maintenance continued", context = TAG) // test hook
                     if (paykitPaymentRequestPollingJob?.isActive == true) {
                         paykitPaymentRequestPollingJob?.cancel()
                         paykitPaymentRequestPollingJob = null
@@ -1405,6 +1406,7 @@ class AppViewModel @Inject constructor(
 
     private suspend fun refreshPaykitMaintenance() {
         if (isPaykitEnabled.value && walletRepo.walletExists()) pubkyRepo.restoreSessionIfNeeded()
+        if (to.bitkit.BuildConfig.DEBUG) Logger.info("QA1686 periodic maintenance continued", context = TAG) // test hook
         if (isAutomaticPaykitWorkPaused()) return
         pubkyRepo.republishIdentityIfNeeded()
         retryPendingPaykitEndpointRemoval(
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..1c83e7b09
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,231 @@
+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
+                "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.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}"
+                }
+                "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.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
+                    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
+                    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 hook

I retained these passes from the 89e2702 device report, after checking that their restore/backup paths and native pins remain unchanged:

  • Restore and backup without an identity.
  • Complete-backup protection during temporary identity loss.
  • Restart with saved identity and one contact.
  • Connection correction across required recovery and restart.
  • Ordinary backup after profile sign-out.
  • Ordinary backup after adopted-identity unlink, using the report’s controlled Ring-contract provider.

Both previously failing cases were driven again above. No planned case remains unrun. Funded payment replay and Ring application UI were not driven. The inherited payment-confirmation, fee-fallback and background-admission changes received source review in this run.

Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0b77f099 matches deferred activation, missing-profile retries and complete-backup protection. The terminal backup limitation remains open on both platforms; Android preserves an identity already activated during required metadata restore.

Coverage: contiguous delta 89e2702..9ce4302, all 36 changed paths and open concerns, with an independent source review. The current base is 0924ae5; the tested head includes that composition.


Reviewed by gpt-6.1-sol-high

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA review

Scope: Full reassessment of the entire PR diff against merge base 0924ae5d, at 9ce43027. The merge base changed and no verified baseline was supplied, so inherited coverage is empty.

No new actionable code findings.

Required wallet and USDT restore still finishes before ordinary wallet actions are released. Optional Pubky activation is scheduled afterward, one job per stored-identity revision, and an identity already signed in by metadata restore stays active while its profile and contacts refresh. Reconnect, resume, and payment-request maintenance start a missing own-profile read for the current sign-in and return immediately; a loaded profile stops further retries, and a replaced identity cannot receive the late result. METADATA and WALLET uploads fail when identity data cannot be read, leaving the previous complete remote record in place.

The open terminal-restoration WALLET backup report still matches this head: retained credentials with no SDK identity make later ordinary WALLET uploads fail, so newer wallet state stays pending. Redundant session import and missing profile retry are addressed in the current source. ovi-reviewer reports the profile retry recovered on a device at this commit.

Compared with bitkit-ios#1121 at 0b77f099 for the restore, profile-retry, and WALLET-backup contracts: the new journey matches, and both apps refuse a WALLET snapshot without an SDK session and retry a missing profile in the background. Android keeps an already-activated session; iOS clears in-memory authentication before scheduled init.

Validation: PubkyRepoTest, BackupRepoTest, and WalletViewModelTest were inspected. Hosted build and lint succeeded on this commit. J1 remains unexecuted; its complete-app fault controls are not in the repository.

Device testing: not performed in this review.

Reviewer model: grok-4.7
Reasoning effort: high

@ovitrif

ovitrif commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Updated this branch with master so its approval sticks: the ruleset dismisses approvals on a branch behind its base. No change to the PR's own code.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman please resolve conflicts.

Copy link
Copy Markdown
Contributor Author

Resolved the current-master conflict in signed additive merge 217b42e. Both sets of BackupRepoTest cases are retained. The final combined tree passed 4,709 tests (zero failures/skips), compile, unchanged formatting, Detekt and Android Lint; complete reports were inspected. All 10 live-base commits verify locally and on GitHub. New-head hosted checks are running. The existing terminal full-WALLET availability finding and authenticated J1 gap remain open; this composition does not claim to fix those.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 returned identity_error before 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 hook

Reviewed by gpt-6.1-sol-high

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants