Repository navigation
fix: decouple wallet restore from profile refresh - #1686
ben-kaufman wants to merge 10 commits into
Conversation
|
Triage: 3.1.0. It keeps the shipped wallet restore from waiting on profile refresh, and 3.1.0 is due 28 Oct. |
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryWallet restore now schedules optional identity activation and profile reads without waiting for them.
No actionable issues found. Review covered source and tests; tests were not run. Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Required wallet and USDT recovery] --> B{Succeeded?}
B -->|No| C[Keep restore blocked]
B -->|Yes| D[Schedule identity work]
D --> E[Complete wallet restore]
D --> F{Identity revision still current?}
F -->|No| G[Skip obsolete work]
F -->|Yes| H[Activate identity and load profile]
I[Build backup] --> J{Identity data complete?}
J -->|No| K[Keep previous remote backup]
J -->|Yes| L[Upload backup]
Reviews (1) · Last reviewed commit: "fix: decouple wallet restore from profil..." · Reviewed by Greptile |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Moves Pubky identity activation after a wallet restore off the restore screen's critical path (scheduleInitializationAfterRestore, coalesced per stored-identity revision), and makes WALLET and METADATA backups fail rather than upload a payload with the identity part silently missing. Reviewed ac2a7c6, full tier, stacked on #1685 (fix/pending-wallet-restore-admission), reviewed as the delta against that base.
What I checked, and 6 candidates I ruled out
Read in full: PubkyRepo.kt (initialization, restore, adoption, sign-out and backup-state regions), BackupRepo.kt (scheduling, triggerBackup, markBackupFailed, wallet and metadata snapshots, full restore), WalletViewModel.kt restore paths, PrivatePaykitRepo.backupSnapshot, PaykitSdkService (currentPublicKey, session provider, clearSessionAccess), and paykit-rs v0.1.1-rc1 identity_status / initialize.
Call sites traced: scheduleInitializationAfterRestore (both WalletViewModel restore paths), hasIdentityForBackup (only captureWalletBackupDataBytes), initialize() (still only the init block, unchanged semantics with expectedRevision = null).
CI: lint, detekt and build-local green. build is red on one test, PubkyAuthManifestTest > signup and authorization links resolve only through their enabled aliases, at its first assertion (line 77, every alias still at its default). The diff touches neither that test nor the manifest; the base head 837aa1c passed build, and the same test failed the same way in two of the last eight failed ci runs on unrelated branches (fix/update-terms-of-use, feat/usdt-design-followup-20261015). All seven new tests in PubkyRepoTest, BackupRepoTest passed in that run. The e2e shards were still running when I read the checks.
Not built here: this review is reasoned from the code and CI; CI is the gate.
Ruled out
- Scheduled activation lost when the revision moves: the revision only moves through
startSignIn(non-restoring) or a non-preservingclearAuthenticatedState, both of which are a newer identity's own path, so dropping the stale job is the intent.restoreSessionBackupStatebumps it inside the restore, before the schedule reads it. - Something after
Completedneeding the identity: the public key is already set inside the required boundary (restoreSessionBackupStateLocalSeedstartSignIn), and the payment-requestactivateatBackupRepo.kt:1058runs during the restore, unchanged. - Coalescing reusing a finished job:
takeIf { !it.second.isCompleted }, and the completion handler clears the slot only when it still holds the same job. - Uncaught failure in the background job:
initializeSessioncatches its own failures,checkAdoptedSourcereturns aResult, andappScopecarries aSupervisorJobplus a logging handler. - WALLET backup blocked for good while a saved session exists: with a stored session
currentPublicKey()reads the bound identity state throughidentity_status, sostateis null only when the SDK has no identity, which is the case the check means to hold; an unreadable keychain fails the backup (covered bywallet backup cannot treat unreadable identity as absent). - METADATA
getOrThrowturning a benign miss into a failure: both snapshots returnnullfor absence insiderunSuspendCatching, so only a read error now fails, which is what the two new tests pin.
Merge confidence: 3/5, the build job is red on PubkyAuthManifestTest, which I traced to a flake outside this diff; a green re-run would move it to 4/5 (J1 is still unrun).
Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: one LOW inline, reproduced with a failing unit test.
Checked and clean:
- No cross-identity leak on wipe and restore: the mutex, the revision and the
isCurrent(signIn)guards hold. - Identity isn't lost for good: the retry paths cover a failed background refresh. The WALLET backup that can stay blocked is the open P1 thread.
- No payment path starts before the identity exists: the key is set inside the required restore.
- Twin synonymdev/bitkit-ios#1121 keeps the header signed-out until the background activation lands, so it has no flip.
Performance-relevant (the purpose is a faster restore). wallet/restore-during-identity-outage.xml needs fault controls that aren't provisioned, so the gate uses a proxy. It restores a regtest wallet whose backup carries a pubky session, then:
- times
Full restore success→ Completed, head vs master, with the homeserver reachable and with it blocked; - checks that the header shows the identity and that profile and contacts arrive afterwards;
- counts
Restored paykit session forper restore (expected 1); - runs Settings → Backup for WALLET once the identity is active.
Device gate (partial): ac2a7c6 — not run yet; pending that proxy run. Device results will follow in a separate review.
There was a problem hiding this comment.
Verdict:
Findings: No new findings. I confirmed that the redundant session import is fixed at 0fd5e42. The acknowledged terminal-restoration backup limitation still applies to Android: retained credentials with no SDK identity can block later complete WALLET backups. Temporary recovery below does not resolve that case.
QA for 0fd5e42: Android 16, API 36 emulator, staging regtest, an unfunded disposable wallet. I built the unmodified head and ran its focused native suites, then drove the app with local Debug controls over the real SDK and VSS paths. Installed APK bytes matched the built artifacts.
Test 1: J1 — complete optional identity outage journey ⏭️ Partial: activation first occurring after required recovery was not driven.
This LocalSeed backup activates its identity during required metadata restoration. Its later scheduled work enriches an already-active identity, so the first action's unauthenticated-completion expectation was not established. The subcases below cover held enrichment, unavailable SDK export, restart, stale profile rejection and required-recovery failure. Initialization with authentication unavailable was held separately across process restart.
Genuine post-required activation failure and funded payment replay remain unverified.
Test 2: J1 — restore with optional enrichment held ✅ Restore completed and ordinary wallet access remained available before release.
The success screen appeared while final enrichment was held. I left restore and opened Receive. App logs confirmed a running node, clear required-recovery markers and successful new-address allocation. Android kept the identity activated by required metadata restoration; the profile was still empty until enrichment resumed.
J1-restore2x.2x.mp4
Required restore, wallet access and receiving while enrichment is held; 2×. Node, marker and allocation state were checked in app logs.
Test 3: J1 — complete-backup protection and recovery ✅ An unavailable SDK identity preserved the remote WALLET record, then recovery permitted a complete backup.
The control removed live SDK session access, retaining the original identity secret for recovery. The real WALLET backup failed with Paykit identity is unavailable for backup; remote bytes and version stayed unchanged and backup work remained pending. Real sign-in with the original secret then allowed a fresh SDK export and complete upload, with pending status cleared. These assertions were checked in the control and app logs.
J1-backup-final2x.2x.mp4
Rejected backup, identity recovery and resumed profile display; 2×. Backup contents and pending status were checked in app logs.
Test 4: J1 — process restart during held work ✅ Saved credentials recovered the same identity and a single saved contact.
I killed the app during held optional work and held initialization again after relaunch. Authentication stayed unavailable while the ordinary wallet ran. Releasing initialization restored the identity and the same one contact. The contacts screens and app logs confirmed the result; no payment was submitted.
J1-restart2x.2x.mp4
One contact before and after process restart and initialization release; 2×. Authentication and node state were checked in app logs.
Test 5: J1 — delayed profile response after identity removal ✅ The late response did not restore the removed identity or profile.
I held a real profile response, removed the identity through the normal repository sign-out, then released the response. Its normal guard reported current=false; authentication and profile remained empty. The UI and app logs confirmed that the old profile was not applied.
J1-stale2x.2x.mp4
Identity removal while a profile response is held, then release; 2×. Stale-response rejection was checked in app logs.
Test 6: J1 — required recovery failure and retry ✅ Required markers blocked address and payment admission until the original payload was repaired and restored.
A malformed retained WALLET payload kept the restore-error screen with no Proceed action. Address allocation returned WalletBackupRestorePendingError. An invoice-admission probe returned the same error before invoice parsing or dispatch. After repairing the original retained WALLET and USDT-recovery payload copies, Retry completed required recovery while optional enrichment was still held. Markers cleared and address allocation succeeded. These states were checked in app logs.
J1-required-final2x.2x.mp4
Required-recovery error, repair, retry and success with enrichment held; 2×. Operation admission was checked in app logs.
Test 7: base comparison — held optional initialization ✅ The base waited for release; the reviewed head completed with the hold active.
I built and drove 55b31ee1 on the same emulator with the same disposable backup and a hold at the corresponding optional-initialization boundary. Required restore logged success, but the base remained at 100% throughout the 34.6-second hold. It completed after release. Test 2 shows the reviewed head completing before release. This is a controlled comparison, not a live homeserver-blackout benchmark.
C2-base2x.2x.mp4
Pinned base waiting after required recovery, then completing after release; 2×. The hold interval was measured from app logs.
Check 1: focused native regressions ✅ The unmodified-head build and selected suites passed without failures or skips.
./gradlew assembleDevDebug testDevDebugUnitTest \
--tests to.bitkit.repositories.PubkyRepoTest \
--tests to.bitkit.repositories.BackupRepoTest \
--tests to.bitkit.ui.WalletViewModelTestI inspected the test XML and complete build log. The suites cover coalescing, active-session preservation, replacement identities, unreadable credentials, complete-backup protection and required-recovery admission.
Local Debug controls and deciding log lines
Not pushed. Holds wait for explicit file removal. The backup control clears this disposable wallet's SDK session access and recovers using its original secret. The required-recovery control corrupts retained local payload input and repairs it from the captured original before Retry. SDK results and recovery decisions are not mocked.
Full restore success
QA1686 final enrichment held
identity=true, profile=null, contacts=1, pendingRestore=false, node=Running
QA1686 unavailable identity: failed=true, unchanged=true, pending=true
QA1686 qa1686BackupFault: backup protection and recovered export passed
QA1686 profile response held
QA1686 qa1686SignOut: identity removed
QA1686 profile released; current=false
QA1686 qa1686SpendingGate: payment admission blocked by required restore before invoice parsing
QA1686 qa1686RepairRequired: repaired original required backup
Full restore success
QA1686 final enrichment held
Head controls:
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..7aaf9c1ac 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -42,6 +42,11 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) { // test hook
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { // test hook
+ Qa1686.run(requireNotNull(context), method) // test hook
+ } // test hook
+ } // test hook
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 41854bbf9..9b0179f2f 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -375,6 +375,12 @@ class PubkyRepo @Inject constructor(
private suspend fun initializeForRevision(
expectedRevision: Long? = null,
): Result<Unit> = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/" + if (expectedRevision == null) "qa1686-hold-initial" else "qa1686-hold-final") // test hook
+ if (gate.exists()) Logger.info("QA1686 final enrichment held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 final enrichment released", context = "TestHook") // test hook
+ } // test hook
val (restored, revision) = initializeMutex.withLock {
if (expectedRevision != null && expectedRevision != storedIdentityRevision.get()) {
return@withContext Result.success(Unit)
@@ -842,6 +848,12 @@ class PubkyRepo @Inject constructor(
?: throw AppError("Profile not found")
}
}.onSuccess { loadedProfile ->
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-profile") // test hook
+ if (gate.exists()) Logger.info("QA1686 profile response held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 profile released; current=${isCurrentLoad()}", context = "TestHook") // test hook
+ } // test hook
var isCurrent = false
_profile.update {
isCurrent = isCurrentLoad()
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..f52d01a3f
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,125 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+
+object Qa1686 { // test hook
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "qa1686Capture" -> { // test hook
+ File(context.filesDir, "qa1686-mnemonic").writeText(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ for (c in listOf(BackupCategory.METADATA, BackupCategory.SETTINGS, BackupCategory.WALLET)) d.backupRepo().triggerBackup(c).getOrThrow() // test hook
+ check(d.vss().getObject("WALLET").getOrThrow() != null) // test hook
+ "captured complete backup and test credentials" // test hook
+ } // test hook
+ "qa1686Clipboard" -> { // test hook
+ withContext(Dispatchers.Main) { // test hook
+ context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) // test hook
+ } // test hook
+ "copied test credentials" // test hook
+ } // test hook
+ "qa1686BackupFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) // test hook
+ d.pubkyService().forgetSessionAccess() // test hook
+ check(d.pubkyService().currentPublicKey() == null) // test hook
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET) // test hook
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true // test hook
+ val same = before.value.contentEquals(after.value) && before.version == after.version // test hook
+ Logger.info("QA1686 unavailable identity: failed=${failed.isFailure}, unchanged=$same, pending=$pending", context="TestHook") // test hook
+ check(failed.isFailure && same && pending) // test hook
+ d.pubkyService().signIn(secret) // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ val fresh = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ val complete = json.decodeFromString<WalletBackupV1>(fresh.value.decodeToString()).paykitSdkBackupState != null // test hook
+ val synced = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == false // test hook
+ check(complete && synced) // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "backup protection and recovered export passed" // test hook
+ } // test hook
+ "qa1686Recover" -> { // test hook
+ val secret = d.pubkyService().deriveSecretKey(requireNotNull(d.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name))) // test hook
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secret) // test hook
+ d.pubkyService().initialize() // test hook
+ d.pubkyRepo().restoreSessionBackupState(to.bitkit.models.PubkySessionBackupV1(kind = to.bitkit.models.PubkySessionBackupKind.LocalSeed)).getOrThrow() // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow() // test hook
+ d.backupRepo().startObservingBackups() // test hook
+ "recovered identity and fresh complete backup" // test hook
+ } // test hook
+ "qa1686Refresh" -> { // test hook
+ d.pubkyRepo().scheduleInitializationAfterRestore() // test hook
+ "scheduled optional enrichment" // test hook
+ } // test hook
+ "qa1686RequiredFault" -> { // test hook
+ d.backupRepo().stopObservingBackups() // test hook
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow()) // test hook
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value) // test hook
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, "invalid-json") // test hook
+ "staged malformed retained required backup" // test hook
+ } // test hook
+ "qa1686RepairRequired" -> { // test hook
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ "qa1686SignOut" -> { // test hook
+ d.pubkyRepo().signOut().getOrThrow() // test hook
+ "identity removed" // test hook
+ } // test hook
+ else -> "identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hookBase-comparison controls:
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..30160f67f 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -42,6 +42,9 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) { // test hook
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { Qa1686.run(requireNotNull(context), method) } // test hook
+ } // test hook
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 39a03aefb..17e79f4e1 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -351,6 +351,12 @@ class PubkyRepo @Inject constructor(
}
suspend fun initialize() = withContext(ioDispatcher) {
+ if (to.bitkit.BuildConfig.DEBUG && keychain.exists(Keychain.Key.BIP39_MNEMONIC.name)) { // test hook
+ val gate = java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-final") // test hook
+ if (gate.exists()) Logger.info("QA1686 base optional initialization held", context = "TestHook") // test hook
+ while (gate.exists()) delay(200) // test hook
+ Logger.info("QA1686 base optional initialization released", context = "TestHook") // test hook
+ } // test hook
val restored = initializeMutex.withLock { initializeSession() }
if (restored) loadIdentityData()
checkAdoptedSource()
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..e40475dbe
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,27 @@
+package to.bitkit.dev // test hook
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import java.io.File // test hook
+object Qa1686 { // test hook
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { fun pubky(): PubkyRepo; fun backup(): BackupRepo; fun lightning(): LightningRepo } // test hook
+ suspend fun run(context: Context, method: String): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val text = if (method == "qa1686Clipboard") { // test hook
+ withContext(Dispatchers.Main) { context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("test seed", File(context.filesDir, "qa1686-mnemonic").readText())) } // test hook
+ "copied test credentials" // test hook
+ } else "identity=${d.pubky().publicKey.value != null}, profile=${d.pubky().profile.value?.name}, contacts=${d.pubky().contacts.value.size}, pendingRestore=${d.backup().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ return Bundle().apply { putString("result", text) } // test hook
+ } // test hook
+} // test hookCoverage: full 55b31ee1..0fd5e42, all nine changed files and reached restore, identity replacement, backup and SDK paths. Earlier connection-editor steps, a USDT-specific restore failure, terminal identity failure on device, a live homeserver blackout and funded payment replay were not driven.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh matches the optional scheduling and complete-backup protections. Android preserves an identity already activated during required metadata restoration.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
jvsena42
left a comment
There was a problem hiding this comment.
Code scan of d4ba907: no findings. PubkyRepo.kt:382-386 now skips the import when _publicKey is already set for the stored revision, so a scheduled restore keeps the active identity. The WalletViewModelTest.kt conflict resolution and the journey edit are sound.
Expected for the proxy run: no Restored paykit session for after Full restore success for a LocalSeed backup (was 1), one for an ExternalSession backup.
Device gate (partial): 0fd5e42 — not run yet; still pending the identity-outage proxy run. Device results will follow in a separate review.
|
Device gate (partial):
Still to settle before the final line: after the outage, head didn't reload the own profile. The header showed "Your Name" and the own Contacts row only the key from 10:41:33 to 10:44:44 (
|
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: full review of the PR diff against its merge base 55b31ee, at 0fd5e42. No saved baseline existed, so nothing was carried over. I also compared the restore scheduler and the WALLET backup guard against the iOS twin #1121 at 39fcaec, and checked composition with #1685 at 7b823d2. Neither of those is a full review of the companion PR.
No new actionable code findings.
- Restore decoupling: the restore now reaches
Completedwithout waiting for optional identity setup. That setup is only scheduled afterperformFullRestoreFromLatestBackupsucceeds, on both the VSS path and the RN path, so required WALLET/USDT recovery still comes first. Duplicate requests for the same identity revision are merged into one. Revision checks before and after profile/contact loading drop work for an identity that has since been replaced or removed. The job runs in the supervisor-backed repository scope, so a failure in background setup is logged and cannot crash the app or turn a completed restore into a failed one. The iOS twin uses the same model. - Earlier redundant-import thread: this thread is fixed at this head. Scheduled setup now skips
initializeSession()when the restore has already activated the identity (PubkyRepo.kt:382-386), and the newPubkyRepoTestregression covers that case. I checked this by reading the source and the test, not by running it. - Existing open concern (not counted above): after a terminal Paykit restoration failure, saved credentials stay in place and the new guard keeps failing every WALLET backup. That WALLET backup also holds transfers, reservation indices, watch-only accounts and payment-proof state. This is the mechanism already tracked in the iOS #1121 thread, and the author confirmed there that it applies to Android. On Android the guard throws a non-temporary
IllegalStateException, so there is no timed retry. It is only retried when a later change re-marks the WALLET backup as required, and the periodic backup-failure notification applies. - Dependency composition: #1685 has moved to
7b823d2, past this PR's base. A local trial merge of this head with that revision merged without conflicts and adds no new restore-timepubkyRepo.initialize()callers. I did not compile or test the merged tree.
Validation: code-only review. I did not run any Gradle tasks, unit tests or E2E. The author reports green CI, lint and E2E at 0fd5e42. Those runs do not cover J1, whose fault controls are not provisioned.
Device testing: not performed in this review.
Recommended before device testing: settle the terminal-restoration WALLET backup behaviour in the linked iOS thread, since it applies to both platforms.
Suggested additional test cases
- Android, wallet with no Pubky profile, so the backup's
pubkySessionis empty: restore from VSS, then make a wallet change such as receiving on-chain. Expected: Settings → Backup shows the WALLET backup succeeding, and the new identity guard does not block a wallet that has no identity. - Android, wallet with an active Pubky profile: sign out of the profile, then make a wallet change. Expected: the WALLET and METADATA backups both succeed afterwards, with no lingering backup-failure notification, which confirms sign-out clears every credential the guard checks.
Reviewer model: claude-opus-5-5
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Approving with one LOW open, inline at PubkyRepo.kt:382: the own profile isn't reloaded after an outage during restore. Fix it here or move it to a follow-up issue, your choice; reply on the thread saying which.
Device gate: 0fd5e42, Gate_Throwaway emulator through a local homeserver fault proxy, base = merge-base 55b31ee. LocalSeed backup: a profile created in Bitkit on staging, with two contacts.
- perf:
Full restore success→ Completed took 2.0–2.2 s on head with the homeserver reachable, held or blackholed. Base took 12.75 s reachable, 103.65 s held, and 192–259 s blackholed (it waits on re-sign-in:Failed to restore paykit session, attempting re-sign-in). - Head keeps the identity through the outage and lists the contacts. Base drops the identity and shows the Contacts intro until the outage ends.
- The LOW above reproduced in 2 of 2 runs.
- After release, the WALLET backup on head took about 95 s, through repeated
Retrying backup for: 'WALLET' … acquire Pubky write lock. Base took about 23 s with one retry. This is the same pubky-sdk write-lock window seen on synonymdev/bitkit-ios#1124, which is not Bitkit code, so I haven't raised it here.
restore-during-identity-outage.xml steps 2 and 6. With a LocalSeed backup the identity activates inside the metadata restore, before a fault can start, and VSS shares a staging host the proxy can't isolate. Step 4 was timed only through the restore measurement above. An ExternalSession backup wasn't tested.
CI is green at 0fd5e42.
|
Device check: a native crash on backgrounding with VSS unreachable. Not from this PR: master crashes the same way. The crash is a SIGABRT in Setup: Pixel_9 emulator, a fresh wallet, the VSS host blocked by DNS with Electrum up, the cached-apps freezer disabled, and a SETTINGS backup pending (denomination change) before Home.
Master's crash has the same abort message and the same 39-frame backtrace; only one frame offset differs: So it is an existing ldk-node shutdown race, not something the bitkit-core or vss-client bumps in this PR introduce. My approval stands. The crash needs its own issue against ldk-node. |
|
Thanks; this matches the already-open native shutdown tracker #1536. I linked your exact head/master comparison there, preserving the VSS/SETTINGS prerequisites, identical-library claim and small-sample limits. The new data remains attributed external evidence until the raw symbols/tombstones are inspected. No duplicate ldk-node issue or app-scope shutdown rewrite is being introduced, and no existing CI failure is waived. |
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the entire pinned PR at 89e2702e against merge base c747cc0b, after the comparison base changed. No coverage was inherited.
No new actionable code findings.
The previously reported HIGH terminal-identity backup concern and LOW profile-refresh retry concern remain unresolved at this head. Temporary recovery does not resolve terminal backup availability. Existing execution reports retain their original revisions.
Targeted comparisons with iOS c0b1e86f and dependency Android #1685 at c747cc0b checked optional initialization, authentication presentation, required-recovery admission and backup protection; neither received a full companion review.
Validation: matching-head CI and lint succeeded. Their synthetic merge tree equals the pinned head; the CI log records all 4,607 tests passing, including the changed regressions. Local diff-whitespace and journey XML checks passed. Focused Gradle tests stopped before execution because the Android SDK is unavailable. Current-head E2E is still in progress.
Device testing: not performed in this review. J1 remains unexecuted here and its complete-app fault controls are not committed. Resolve the existing concerns and provision those controls before treating the change as ready for a complete device pass.
Suggested additional test cases:
- Android: restore a wallet with no Pubky identity, make an ordinary wallet change, and verify WALLET and METADATA backups synchronize.
- Android: disconnect an adopted Ring identity, make an ordinary wallet change, and verify retained source credentials do not prevent subsequent backup synchronization.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
|
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. |
There was a problem hiding this comment.
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 hadprofile=nulland 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
There was a problem hiding this comment.
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 returnedidentity_errorbefore 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 hookI 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
left a comment
There was a problem hiding this comment.
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
|
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. |
|
@ben-kaufman please resolve conflicts. |
|
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
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the entire PR at 217b42ef against merge base e28307b9, after the comparison base changed. No coverage was inherited. Targeted restore, profile-retry and backup contracts were compared with iOS 0b77f099; this was not a full iOS review.
No new actionable code findings.
The existing HIGH terminal-identity WALLET backup concern remains unresolved: newer ordinary wallet state can remain unbacked up while identity recovery fails. The earlier redundant-import and missing-profile retry mechanisms are addressed in the pinned source and regression tests.
Validation: hosted CI passed 4,709 tests with zero failures or skips, including the changed regressions; lint succeeded. Both used a synthetic merge with the exact reviewed tree. Local focused Gradle tests stopped before execution because the Android SDK is unavailable; diff-whitespace and changed journey XML checks passed. E2E is still running.
Device testing: not performed in this review. Authenticated J1 acceptance remains unverified here; provision its selective identity/profile fault controls and resolve the terminal backup concern before treating the change as ready for a complete device pass.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal-identity WALLET backup failure still reproduces at 217b42e.
Findings: No new findings in the contiguous delta 9ce4302..217b42e.
- Existing HIGH — terminal identity failure blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:784. With an invalid saved session and no recovery secret, a new ordinary watch-only reservation remained pending. The real SDK returnedidentity_errorbefore the explicit completeness guard; remote WALLET bytes/version stayed unchanged. Restoring the original credentials allowed complete upload. This adds evidence to the acknowledged report; it is not a duplicate finding.
QA for 217b42e: Android 16 / API 36, Pixel 7 emulator, disposable unfunded staging/regtest wallet. I built the unmodified head and then used Debug controls to stage credentials and retained recovery inputs and inspect actual SDK/VSS state. The installed APK matched its build; native libraries matched the unmodified APK. Production source/build inputs matched the pinned head. Videos play at 2×; each preserved all changed frames from its original.
Ordinary wallet backup after terminal identity failure ❌ Debug comparisons confirmed newer ordinary state remained pending.
The real resolver rejected an invalid saved session with no recovery secret. Reserved watch-only account index 1 and attempted WALLET backup: SDK identity error, unchanged previous remote bytes/version, pending newer work. Address allocation and Receive remained usable. Restoring the original credentials restored complete WALLET upload. Credential, reservation and remote-backup assertions were checked through Debug output and app logs.
terminal-backup2x.2x.mp4
Ordinary wallet and Receive during terminal identity failure; backup assertions were checked separately.
J1 — required wallet recovery blocks payment links ✅ Required recovery retained operation gates across restart, then repair restored access.
Staged a malformed retained WALLET envelope. Payment links did not open payment review; direct invoice admission and address allocation returned WalletBackupRestorePendingError. Restart retained the restricted error screen with no Proceed action. An explicit retry rejected the shared envelope in restoreUsdtRecovery(), before the later WALLET import; that decoder result was checked separately in app logs. The earlier isolated WALLET-import decoder evidence remains at its original revision.
Repairing the original envelope and Retry completed recovery. Address allocation succeeded, clearing recovery did not replay the old link, and a fresh link reached the normal insufficient-balance check. Gate and decoder assertions were checked through Debug output and logs.
required-wallet2x.2x.mp4
Payment-link refusal, restart into required recovery, repair and restored Home.
J1 — required USDT recovery blocks payment links ✅ Decoder failure retained operation gates; repair restored ordinary receiving.
Staged a malformed retained USDT envelope. A payment link remained outside review; invoice admission and address allocation were blocked. Restart retained the restricted screen; Try Again rejected the real envelope decoder and offered no Proceed action. Restored the original envelope, retried successfully and opened Receive. Decoder and admission results were checked through Debug output and app logs.
required-usdt-final2x.2x.mp4
Required-recovery error and retry, restored wallet, then ordinary receiving.
Check: unmodified build and focused regressions ✅ The pinned head built and every selected suite passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting BackupRepoTest, PubkyRepoTest, AppViewModelSendFlowTest, WalletViewModelTest, PaykitPaymentRequestRepoTest, PaykitPaymentRequestRepoSubscriptionTest, PaykitSdkServiceTest and AddContactViewModelTest. Inspected complete build/test output and available JUnit results. Diff whitespace and changed journey XML checks passed. Build output retained startup-provider, native-symbol stripping, JVM sharing and Gradle deprecation notices.
Prior passing device evidence is retained below at its original head. Restore, identity/profile, backup and native dependency inputs are unchanged. The SDK helper extraction preserves those calls; the changed shared send-admission path was driven above. These are retained passes, not new executions at 217b42e.
Profile reconnect without blocking maintenance ✅ Retained pass at `9ce4302`.
Original device report. Real profile read failed offline, then reconnect scheduled one held retry. Three overlapping maintenance calls returned in 6ms, original sign-in remained current, address allocation succeeded, and Home loaded QA restore after release without Profile navigation. Further calls after success started no new read.
Late retry after identity removal ✅ Retained pass at `9ce4302`.
Original device report. A real successful profile response triggered by restoreSessionIfNeeded was held; normal sign-out completed before release. Existing guard rejected the stale result, credentials/authentication/profile remained absent, and Contacts showed its intro.
Required restore before final identity activation ✅ Retained pass at `9ce4302`.
Original device report. After repairing original WALLET/USDT payloads, real required VSS restore completed with final activation held and saved credentials retained. SDK/repository identity was unavailable, both required markers cleared, and ordinary receiving worked. A complete-backup attempt preserved remote bytes/version and pending work. Releasing activation recovered the identity and one contact, then a fresh complete WALLET/METADATA upload succeeded.
Restore and backup without a Pubky identity ✅ Retained pass at `89e2702`.
Original device report. Restored the real no-identity VSS backup, reached wallet restored, allocated an ordinary address and synchronized WALLET and METADATA. SDK identity state was absent as expected. State and backup assertions were checked in Debug control output.
Unavailable SDK identity protects complete WALLET and recovers ✅ Retained pass at `89e2702`.
Original device report. Real SDK access loss rejected WALLET backup with prior bytes and version unchanged and pending work retained. Re-signing in with the original secret enabled fresh SDK export and complete upload. Debug assertions verified remote contents and pending state.
Restart resumes saved identity and one contact ✅ Retained pass at `89e2702`.
Original device report. Killed the app during held scheduled work and held initialization after relaunch. The ordinary wallet and receive-address allocation remained available. Releasing initialization recovered the same identity and exactly one saved contact, confirmed in UI and control output. No payment was submitted.
Connection correction survives required recovery and restart ✅ Retained pass at `89e2702`.
Original device report. Unreachable local Electrum caused the restricted recovery screen. Saved a reachable replacement; an independent failed RGS probe retained the earlier correction. Held required metadata across three foreground cycles with one node restart total in that process. Restart retained the endpoint and admission gates. Retry restored the old SETTINGS backup while preserving the explicit Electrum correction and existing RGS/USD preferences; both markers then cleared and address allocation succeeded. Raw settings and restart count were checked from app data/logs.
Ordinary wallet backup after profile sign-out ✅ Retained pass at `89e2702`.
Original device report. Successful sign-out cleared source, secret and session credentials. An ordinary new address succeeded, and WALLET/METADATA backups synchronized with no SDK identity. The Data Backups screen and control assertions verified completion.
Ordinary backup after adopted identity unlink ✅ Retained pass at `89e2702`.
Original device report. Bitkit discovered a same-signature Ring-contract provider, imported its real disposable identity through the normal choice UI, and stored an adopted source with no local secret. Normal Edit Profile unlink cleared source/session/secret credentials while the provider still advertised the identity. A new ordinary address and fresh WALLET/METADATA uploads succeeded. The provider is a controlled stand-in; Ring application UI was not tested.
No planned case remains unrun. Funded payment replay and Ring application UI were not driven. The merged contact-saving, inbox-observation and hardware-fee changes received source review and focused unit coverage; their device journeys were not driven in this run.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh, inspected at 2d4fc719, matches deferred activation, missing-profile retry and complete-backup protection. Android preserves an identity already activated during required metadata restore; iOS clears in-memory authentication before scheduled initialization. The terminal backup limitation remains open on both platforms. This was a targeted twin comparison, not a full iOS review or device run.
Coverage: contiguous delta 9ce4302..217b42e, all changed paths, unresolved concerns and reached recovery/SDK code, with an independent source review. This head additively includes master e28307b9; the PR's restore implementation is unchanged from the previous review. The live head remained 217b42e at final discussion recheck.
Debug controls and deciding output
Not pushed. These controls stage recovery inputs and terminal credentials, call real repository/SDK/VSS operations, and inspect state. They do not mock SDK results or change backup/admission decisions. The optional native-initialization control in the patch was not needed or invoked.
qa1686Terminal: terminal=true; ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true
Backup failed for: 'WALLET' [AppError='code=identity_error, context=restore Pubky grant session from platform provider']
qa1686RepairIdentity: recovered original identity and complete WALLET upload
qa1686SpendingGate: payment admission blocked by required restore before invoice parsing
qa1686AddressGate: new address success=false; error=WalletBackupRestorePendingError
Restore from backup failed [JsonDecodingException: JSON input: invalid-json]
qa1686RepairRequired: repaired original required backup
Full restore success
qa1686AddressGate: new address success=true; error=null
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..c57ec93a7 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -42,6 +42,11 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) {
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) {
+ Qa1686.run(requireNotNull(context), method)
+ }
+ }
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,133 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+
+object Qa1686 { // test hook
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun accounts(): to.bitkit.data.WatchOnlyAccountStore
+ fun settings(): to.bitkit.data.SettingsStore
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "qa1686InitCore" -> { // test hook
+ to.bitkit.async.ServiceQueue.CORE.background { // test hook
+ com.synonym.bitkitcore.initDb(to.bitkit.env.Env.bitkitCoreStoragePath(0)) // test hook
+ com.synonym.bitkitcore.updateBlocktankUrl(to.bitkit.env.Env.blocktankApiUrl) // test hook
+ } // test hook
+ "initialized real native database after app storage path became available" // test hook
+ } // test hook
+ "qa1686QuickState" -> {
+ "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, identity=${d.pubkyRepo().publicKey.value != null}, authenticated=${d.pubkyRepo().isAuthenticated.value}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}"
+ }
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ "created disposable profile"
+ }
+ "qa1686Backup" -> {
+ for (c in listOf(BackupCategory.METADATA, BackupCategory.SETTINGS, BackupCategory.WALLET)) d.backupRepo().triggerBackup(c).getOrThrow()
+ val wallet = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val metadata = requireNotNull(d.vss().getObject("METADATA").getOrThrow())
+ val pending = d.cache().backupStatuses.first().filterKeys { it in listOf(BackupCategory.WALLET, BackupCategory.METADATA) }.any { it.value.isRequired }
+ check(!pending)
+ "backup synchronized; sdk=${json.decodeFromString<WalletBackupV1>(wallet.value.decodeToString()).paykitSdkBackupState != null}; metadataBytes=${metadata.value.size}"
+ }
+ "qa1686RequiredFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, "invalid-json")
+ "staged malformed retained WALLET recovery payload"
+ }
+ "qa1686UsdtFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, "invalid-json")
+ "staged malformed retained USDT recovery payload"
+ }
+ "qa1686Terminal" -> {
+ d.backupRepo().stopObservingBackups()
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name))
+ File(context.filesDir, "qa1686-original-secret").writeText(secret)
+ val session = requireNotNull(d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name))
+ File(context.filesDir, "qa1686-original-session").writeText(session)
+ d.pubkyService().forgetSessionAccess()
+ d.keychain().delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, "qa1686-invalid-session")
+ d.pubkyRepo().initialize().getOrThrow()
+ check(d.pubkyRepo().publicKey.value == null)
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val reserved = d.accounts().reserveAccountIndex(0, "qa1686-terminal-ordinary")
+ check(d.accounts().backupSnapshot().allocationState.highestAccountIndexByWallet["0"] == reserved)
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val same = before.value.contentEquals(after.value) && before.version == after.version
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && same && pending)
+ "terminal=true; ordinary watch-only index=$reserved reserved; failed=true; remote unchanged=true; pending=true; error=${failed.exceptionOrNull()?.javaClass?.simpleName}"
+ }
+ "qa1686RepairIdentity" -> {
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, File(context.filesDir, "qa1686-original-secret").readText())
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, File(context.filesDir, "qa1686-original-session").readText())
+ d.pubkyRepo().initialize().getOrThrow()
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow()
+ "recovered original identity and complete WALLET upload"
+ }
+ "qa1686RepairRequired" -> { // test hook
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ else -> "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, sdk=${d.pubkyService().currentPublicKey() != null}, identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hookReviewed by gpt-6.1-sol-high
Description
Ordinary wallet restore finishes after required wallet and USDT recovery; optional Pubky activation and profile enrichment continue afterward. Scheduled setup preserves an identity already activated by metadata restoration, coalesces requests for the same revision, and rejects obsolete results.
The reconnect follow-up in
9ac9746aeretries a missing own profile from existing reconnect, resume and maintenance calls. It captures the original sign-in, returns without waiting for profile networking, avoids re-importing an active session, and reuses the existing profile-write and overlapping-load guards. A successful load stops these retries. The previous “Your Name” placeholder finding is addressed without adding contact refresh or payment work.Complete WALLET and METADATA capture still rejects unavailable or unreadable identity data instead of replacing a complete backup with an incomplete snapshot. Terminal identity failure can still leave newer ordinary WALLET state pending; the existing HIGH availability finding remains open.
Twin: synonymdev/bitkit-ios#1121. Parent #1685 and fee follow-up #1701 merged externally; this PR now targets
master.Current tree and validation
Head
217b42ef889952a6c1f3a30b0c44a8b1001db4a5includes a signed additive merge of live mastere28307b9d5b8dc7e1e47af04adae32567353b1d0. 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.just format,just compile,just testandjust lintpassed: 4,709 tests / 256 suites, zero failures, errors or skips; Detekt and Android Lint contain zero findings.verified=true,reason=valid).0f7270eeequals this head tree and uses current basee28307b9. 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.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
restore-during-identity-outage.xml: optional activation outage after required restore, complete-backup protection, screen exit, missing-profile reconnect, stale enrichment after identity removal/replacement, restart, and required-recovery failures retaining spending/address gates.The new reconnect action expects wallet maintenance to continue while a real profile read is held, then Home to recover after release without manual Profile navigation. The final candidate has repository-level tests with mocked native/service resources; this is not a complete authenticated app journey. The author's isolated complete-app fault controls for J1 are not yet provisioned.
External review 5688838161 reports real SDK/VSS cases and reproduces both findings at predecessor
89e2702e. Its raw nonvisual artifacts have not been independently verified by the author, and it is not acceptance evidence for the new head. The matching iOS reconnect follow-up is published in #1121 at0b77f099; its iOS evidence does not establish Android acceptance.Evidence and remaining gaps
PubkyRepoTestcovers the new retry and retained coalescing, cancellation, sign-in and profile-write guards. ExistingWalletViewModelTestandBackupRepoTestcover required recovery admission and complete-backup refusal. Full-suite success does not establish process-kill, authenticated service faults or funded payments.The terminal full-WALLET availability finding remains unresolved on both platforms. No stale SDK fallback, partial backup, independent-only writer, legacy migration activation, payment replay or authorization bypass is introduced. Full-wallet redesign and contact CAS are separate work, not substitutes for this PR's acceptance.
Previous revision evidence
At
9ce430274eHead
9ce430274e9657c06bb11e8e91f420cccf6fcb99includes signed additive composition with live master0924ae5d6be2dcc0faa932ac2b9e11de045bd98b. Final tree:3a9ac06d8eb8994078a6a95aa74066935d10f8c0.just formatchanged no inputs;just compile,just testandjust lintpassed. 4,681 tests / 256 suites, zero failures, errors or skips; Detekt and Android Lint SARIF/XML contain zero findings.0f1a4288has the exact head tree.150andTransfer) 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.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
gpt-6-astra(reasoning:xhigh).gpt-6-astra(reasoning:xhigh).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.