fix(auth): destructive Forgot-PIN recovery (Telegram bugs 7, 8)#272
Conversation
Two Telegram-reported bugs fixed with one flow change.
Bug 8 (CRITICAL): Forgot PIN navigated to the regular MnemonicImport
screen, which refuses already-imported seed phrases. A user who
had genuinely forgotten their PIN entered their seed, hit Import,
and got "already imported" — a permanent lockout.
Bug 7 (HIGH): The same nav call used
`popUpTo(Auth) { inclusive = true }`, so the back arrow on the
MnemonicImport screen had nothing to pop back to. Even users who
remembered their PIN after tapping Forgot PIN were trapped.
What this PR does
- New `ForgotPinScreen` + `ForgotPinViewModel` that surface the
destructive cost up front: every local wallet, key, and cache is
wiped; only the recovery phrase can restore access. After the
user confirms, the ViewModel runs `factoryReset` and restarts
the process so the JNI light client comes back up clean.
- `WalletRepository.factoryReset()` — iterates parent wallets and
their sub-accounts, calling the existing `deleteWallet` per row
so per-wallet key + cache + sub-account cleanup runs inside
transactions (a bulk DELETE would orphan EncryptedSharedPreferences
/ key_material). The active-wallet guard in `deleteWallet` is
bypassed once, on purpose, by clearing the active-wallet
preference and `deactivateAll`-ing the DB rows first.
- `WalletPreferences.clearActiveWalletId()` — small helper so the
guard above doesn't need to reach into the SharedPreferences
keys directly.
- NavGraph wiring: `onForgotPin` now routes to `Screen.ForgotPin`
without `popUpTo(Auth)`. The back arrow works. The confirmation
screen itself only triggers wipe on the explicit Erase button.
- Reset sequence mirrors `GatewayRepository.switchNetwork`:
ProcessPhoenix-style relaunch with `FLAG_ACTIVITY_NEW_TASK |
CLEAR_TASK` before `Process.killProcess`. The user lands on
the onboarding entry point with a clean slate and can re-import
via their recovery phrase.
Refs Telegram bug report (Radoslav, 2026-05-21)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR introduces a complete "Forgot PIN" destructive recovery feature for the Pocket Node Android app. Users can now trigger a factory reset that clears all wallets, caches, and PIN from local storage, then restarts the app. The implementation spans data operations, ViewModel orchestration, and Compose UI with error handling throughout. ChangesForgot PIN Destructive Recovery
Sequence Diagram(s)sequenceDiagram
participant User
participant ForgotPinScreen
participant ForgotPinViewModel
participant WalletRepository
participant CacheManager
participant PinManager
participant LaunchActivity
User->>ForgotPinScreen: tap confirm reset
ForgotPinScreen->>ForgotPinViewModel: executeReset()
ForgotPinViewModel->>WalletRepository: factoryReset()
WalletRepository->>WalletRepository: clearActiveWalletId()
WalletRepository->>WalletRepository: deactivateAllWallets()
WalletRepository->>WalletRepository: delete sub-accounts & parents
ForgotPinViewModel->>CacheManager: clear caches
ForgotPinViewModel->>PinManager: removePin(force=true)
ForgotPinViewModel->>LaunchActivity: startActivity with clear task flags
ForgotPinViewModel->>ForgotPinViewModel: killProcess()
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt (1)
373-382: ⚡ Quick winAvoid VACUUM on every wallet delete during bulk reset.
factoryReset()loops through manydeleteWallet()calls, and each call VACUUMs the DB. In this bulk path, VACUUM once at the end to reduce reset time and ANR risk.Refactor sketch
-suspend fun deleteWallet(walletId: String) { +suspend fun deleteWallet(walletId: String, vacuumAfterDelete: Boolean = true) { ... - DatabaseMaintenanceUtil.vacuum(appDatabase) + if (vacuumAfterDelete) { + DatabaseMaintenanceUtil.vacuum(appDatabase) + } } suspend fun factoryReset() { ... - runCatching { deleteWallet(sub.walletId) } + runCatching { deleteWallet(sub.walletId, vacuumAfterDelete = false) } ... - runCatching { deleteWallet(parent.walletId) } + runCatching { deleteWallet(parent.walletId, vacuumAfterDelete = false) } ... + DatabaseMaintenanceUtil.vacuum(appDatabase) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt` around lines 373 - 382, The factoryReset path calls deleteWallet repeatedly which performs a VACUUM on every delete; change deleteWallet to accept a flag (e.g., performVacuum: Boolean = true) or split out the VACUUM into a separate method (e.g., vacuumDatabase()) so bulk callers can skip per-delete VACUUMs; update factoryReset() to call deleteWallet(sub.walletId, performVacuum = false) and deleteWallet(parent.walletId, performVacuum = false) for each account, then call the single vacuumDatabase() (or deleteWallet(..., true) once) after the loop to run VACUUM only once; ensure the new parameter/method is used consistently and tests/Log messages remain accurate (referencing factoryReset and deleteWallet to locate the changes).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt`:
- Around line 376-381: The current factoryReset implementation swallows
per-wallet deletion failures by only logging inside runCatching for
deleteWallet(sub.walletId) and deleteWallet(parent.walletId); update
factoryReset so that any failure from deleteWallet is not suppressed—either
rethrow the caught exception (after logging) or collect failures and throw an
aggregated exception before proceeding with PIN removal/restart; specifically
modify the runCatching blocks around deleteWallet(sub.walletId) and
deleteWallet(parent.walletId) in WalletRepository.factoryReset to propagate
errors instead of merely calling Log.w(TAG, ...).
In
`@android/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinScreen.kt`:
- Around line 129-135: The inline spinner and label in the button use
Spacer(modifier = Modifier.height(8.dp)) which adds vertical space instead of
horizontal; inside the button content (in ForgotPinScreen.kt near
CircularProgressIndicator and
Text(stringResource(R.string.forgot_pin_resetting))) replace the vertical spacer
with a horizontal spacer (Modifier.width(...), e.g., 8.dp) so there is proper
horizontal spacing between CircularProgressIndicator and the Text.
---
Nitpick comments:
In
`@android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt`:
- Around line 373-382: The factoryReset path calls deleteWallet repeatedly which
performs a VACUUM on every delete; change deleteWallet to accept a flag (e.g.,
performVacuum: Boolean = true) or split out the VACUUM into a separate method
(e.g., vacuumDatabase()) so bulk callers can skip per-delete VACUUMs; update
factoryReset() to call deleteWallet(sub.walletId, performVacuum = false) and
deleteWallet(parent.walletId, performVacuum = false) for each account, then call
the single vacuumDatabase() (or deleteWallet(..., true) once) after the loop to
run VACUUM only once; ensure the new parameter/method is used consistently and
tests/Log messages remain accurate (referencing factoryReset and deleteWallet to
locate the changes).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2b1e6e7a-bc1c-424d-883f-58591e7397da
📒 Files selected for processing (6)
android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletPreferences.ktandroid/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.ktandroid/app/src/main/java/com/rjnr/pocketnode/ui/navigation/NavGraph.ktandroid/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinScreen.ktandroid/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinViewModel.ktandroid/app/src/main/res/values/strings.xml
| runCatching { deleteWallet(sub.walletId) } | ||
| .onFailure { Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) } | ||
| } | ||
| runCatching { deleteWallet(parent.walletId) } | ||
| .onFailure { Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) } | ||
| } |
There was a problem hiding this comment.
Do not swallow per-wallet deletion failures in factory reset.
At Line 376 and Line 379, failures are only logged and factoryReset() still returns successfully. That can continue into PIN removal + restart with a partial wipe.
Proposed fix
suspend fun factoryReset() {
+ val failures = mutableListOf<String>()
walletPreferences.clearActiveWalletId()
walletDao.deactivateAll()
val parents = walletDao.getAll().filter { it.parentWalletId == null }
for (parent in parents) {
val subs = walletDao.getSubAccountsList(parent.walletId)
for (sub in subs) {
runCatching { deleteWallet(sub.walletId) }
- .onFailure { Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) }
+ .onFailure {
+ Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it)
+ failures += "sub:${sub.walletId}"
+ }
}
runCatching { deleteWallet(parent.walletId) }
- .onFailure { Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) }
+ .onFailure {
+ Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it)
+ failures += "parent:${parent.walletId}"
+ }
}
+ if (failures.isNotEmpty()) {
+ throw IllegalStateException("factoryReset incomplete (${failures.size} wallet deletions failed)")
+ }
Log.d(TAG, "factoryReset: ${parents.size} parent wallet(s) wiped")
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| runCatching { deleteWallet(sub.walletId) } | |
| .onFailure { Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) } | |
| } | |
| runCatching { deleteWallet(parent.walletId) } | |
| .onFailure { Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) } | |
| } | |
| suspend fun factoryReset() { | |
| val failures = mutableListOf<String>() | |
| walletPreferences.clearActiveWalletId() | |
| walletDao.deactivateAll() | |
| val parents = walletDao.getAll().filter { it.parentWalletId == null } | |
| for (parent in parents) { | |
| val subs = walletDao.getSubAccountsList(parent.walletId) | |
| for (sub in subs) { | |
| runCatching { deleteWallet(sub.walletId) } | |
| .onFailure { | |
| Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) | |
| failures += "sub:${sub.walletId}" | |
| } | |
| } | |
| runCatching { deleteWallet(parent.walletId) } | |
| .onFailure { | |
| Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) | |
| failures += "parent:${parent.walletId}" | |
| } | |
| } | |
| if (failures.isNotEmpty()) { | |
| throw IllegalStateException("factoryReset incomplete (${failures.size} wallet deletions failed)") | |
| } | |
| Log.d(TAG, "factoryReset: ${parents.size} parent wallet(s) wiped") | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@android/app/src/main/java/com/rjnr/pocketnode/data/wallet/WalletRepository.kt`
around lines 376 - 381, The current factoryReset implementation swallows
per-wallet deletion failures by only logging inside runCatching for
deleteWallet(sub.walletId) and deleteWallet(parent.walletId); update
factoryReset so that any failure from deleteWallet is not suppressed—either
rethrow the caught exception (after logging) or collect failures and throw an
aggregated exception before proceeding with PIN removal/restart; specifically
modify the runCatching blocks around deleteWallet(sub.walletId) and
deleteWallet(parent.walletId) in WalletRepository.factoryReset to propagate
errors instead of merely calling Log.w(TAG, ...).
| CircularProgressIndicator( | ||
| modifier = Modifier.size(18.dp), | ||
| color = MaterialTheme.colorScheme.onError, | ||
| strokeWidth = 2.dp, | ||
| ) | ||
| Spacer(modifier = Modifier.height(8.dp)) | ||
| Text(stringResource(R.string.forgot_pin_resetting)) |
There was a problem hiding this comment.
Use horizontal spacing between spinner and label in the button.
At Line 134, Spacer(height = 8.dp) is inside a row-like button content, so it won’t create the intended gap between spinner and text.
Proposed fix
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
...
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.width(8.dp))📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| CircularProgressIndicator( | |
| modifier = Modifier.size(18.dp), | |
| color = MaterialTheme.colorScheme.onError, | |
| strokeWidth = 2.dp, | |
| ) | |
| Spacer(modifier = Modifier.height(8.dp)) | |
| Text(stringResource(R.string.forgot_pin_resetting)) | |
| CircularProgressIndicator( | |
| modifier = Modifier.size(18.dp), | |
| color = MaterialTheme.colorScheme.onError, | |
| strokeWidth = 2.dp, | |
| ) | |
| Spacer(modifier = Modifier.width(8.dp)) | |
| Text(stringResource(R.string.forgot_pin_resetting)) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@android/app/src/main/java/com/rjnr/pocketnode/ui/screens/auth/ForgotPinScreen.kt`
around lines 129 - 135, The inline spinner and label in the button use
Spacer(modifier = Modifier.height(8.dp)) which adds vertical space instead of
horizontal; inside the button content (in ForgotPinScreen.kt near
CircularProgressIndicator and
Text(stringResource(R.string.forgot_pin_resetting))) replace the vertical spacer
with a horizontal spacer (Modifier.width(...), e.g., 8.dp) so there is proper
horizontal spacing between CircularProgressIndicator and the Text.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f637be00c1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| runCatching { deleteWallet(sub.walletId) } | ||
| .onFailure { Log.w(TAG, "factoryReset: sub ${sub.walletId} delete failed", it) } | ||
| } | ||
| runCatching { deleteWallet(parent.walletId) } | ||
| .onFailure { Log.w(TAG, "factoryReset: parent ${parent.walletId} delete failed", it) } |
There was a problem hiding this comment.
Fail reset when any wallet deletion fails
factoryReset() currently wraps each deleteWallet() in runCatching and only logs failures, so the function can return success after partial deletion. In the Forgot-PIN flow, executeReset() treats that as success and proceeds to remove the PIN and restart the app, which can leave undeleted wallets/key material while presenting the reset as completed. This is a critical recovery-path inconsistency; the reset should surface failure (or aggregate and throw) when any wallet deletion fails.
Useful? React with 👍 / 👎.
Summary
Two Telegram bug reports fixed with one flow change.
Both root-caused to a single line in
NavGraph.kt:```kotlin
onForgotPin = {
navController.navigate(Screen.MnemonicImport.route) {
popUpTo(Screen.Auth.route) { inclusive = true } // ← trapped the back arrow
}
}
```
The destination was the regular import screen, which refused already-imported phrases (bug 8). The
popUpToremoved Auth from the back stack so the back arrow had nothing to pop (bug 7).What ships
ForgotPinScreen+ForgotPinViewModel— confirmation surface that explains the destructive cost ("every local wallet, key, and cache is wiped; only the recovery phrase can restore access") before any state is touched.WalletRepository.factoryReset()— iterates parents + sub-accounts, reusing the existing per-walletdeleteWalletso key + cache cleanup runs inside transactions (a bulk DELETE would orphan EncryptedSharedPreferences /key_material). The active-wallet guard is bypassed once, on purpose, by clearing the active-wallet pref +deactivateAll-ing the DB rows first.WalletPreferences.clearActiveWalletId()— small helper for the bypass.onForgotPinnow routes toScreen.ForgotPinwithout popping Auth. Back arrow restored.GatewayRepository.switchNetwork: ProcessPhoenix-style relaunch +Process.killProcess. The user lands on the onboarding entry point with a clean slate and can re-import via their recovery phrase.Test plan
./gradlew :app:compileDebugKotlin -x cargoBuildBUILD SUCCESSFUL./gradlew :app:testDebugUnitTest -x cargoBuildBUILD SUCCESSFULRefs Telegram bug report (Radoslav, 2026-05-21)
Summary by CodeRabbit