fix(security): harden app-lock recovery restart

The key-invalidation recovery restart was unreliable in two ways, both in
AppLockViewModel:

1. Same-process self-restart race: restartProcess() did
   context.startActivity(...) immediately followed by Runtime.exit(0) in the
   same process, so ActivityManager could schedule the relaunch into the
   process being killed and drop it — the app just closed, recovering only on
   the next manual launch. Fixed with a ProcessPhoenix-style separate-process
   trampoline (RestartActivity in a distinct ":restart" process, driven by
   ProcessRestarter): it kills the original process by PID and only then
   relaunches, so the relaunch is issued from a process that survives the kill.
   No new dependency; LibreMailApplication early-returns in the ":restart"
   process so it runs no normal startup work.

2. Lost syncNow() enqueue: clearCacheAndRestart() enqueued the post-wipe
   re-sync fire-and-forget, but WorkManager persists the WorkSpec
   asynchronously on its serial task executor, so exiting raced that insert and
   could drop the re-sync (now user-visible after #118: an empty mailbox until
   the next periodic sync). syncNow() now returns its enqueue Operation, and
   clearCacheAndRestart awaits it (bounded by a 5s timeout) before restarting,
   so the WorkSpec is durably persisted first.

CLEAR_PENDING recovery-flag semantics are preserved; the cache wipe still
happens at cold start in DatabaseModule (unchanged).

Tests: JVM unit tests assert the enqueue Operation is awaited before the
restart is triggered (order) and that a timed-out enqueue still restarts;
SyncSchedulerTest pins syncNow() returning the enqueue Operation. The
separate-process kill/relaunch is device-only and noted for on-device
wipe+resync verification.

Closes #99

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 10:09:21 -05:00
co-authored by Claude Fable 5
parent 7e7e92bb02
commit 15c4cd9ae8
8 changed files with 316 additions and 20 deletions
+13
View File
@@ -91,6 +91,19 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Separate-process trampoline for the app-lock key-invalidation recovery restart. Runs in
its own ":restart" process (android:process) so it survives the main process being killed
and can reliably relaunch the app from the outside — a same-process "startActivity then
exit(0)" restart races ActivityManager and can be dropped (the ProcessPhoenix pattern).
Not exported; only ProcessRestarter starts it. Translucent + excluded from recents so it
never flashes UI or lingers in the switcher before it finishes and exits its own process. -->
<activity
android:name=".restart.RestartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":restart"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Shares downloaded attachments with viewer apps via a content:// URI. -->
<provider
android:name="androidx.core.content.FileProvider"