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"
@@ -21,6 +21,7 @@ import org.libremail.reporting.AppLog
import org.libremail.reporting.CrashReporter
import org.libremail.reporting.DiagnosticsCollector
import org.libremail.reporting.RingLogBuffer
import org.libremail.restart.ProcessRestarter
import javax.inject.Inject
@HiltAndroidApp
@@ -60,6 +61,12 @@ class LibreMailApplication :
override fun onCreate() {
super.onCreate()
// Android also instantiates this Application in the separate ":restart" trampoline process
// (see ProcessRestarter / RestartActivity), which exists only to relaunch the app from outside
// a dying main process and is torn down within milliseconds. It must NOT run any of the app's
// normal startup work — crash reporting, WorkManager scheduling, IDLE push all belong to the
// main process. The main process (no ":restart" suffix) is unaffected, so normal launch is too.
if (isRestartTrampolineProcess()) return
// Wire up debug reporting first so crashes during the rest of startup are still captured.
AppLog.install(ringLogBuffer)
crashReporter.install()
@@ -98,6 +105,10 @@ class LibreMailApplication :
if (pushShouldBeActive) idlePushManager.start()
}
/** True when this Application instance is the one Android spun up in the ":restart" aux process. */
private fun isRestartTrampolineProcess(): Boolean =
Application.getProcessName() == packageName + ProcessRestarter.PROCESS_SUFFIX
private companion object {
const val TAG = "LibreMail"
}
@@ -6,6 +6,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
@@ -48,13 +49,18 @@ class SyncScheduler @Inject constructor(
workManager.enqueueUniquePeriodicWork(PERIODIC_WORK, PERIODIC_POLICY, request)
}
/** One-shot sync, e.g. right after an account is added. */
fun syncNow() {
/**
* One-shot sync, e.g. right after an account is added. Returns the enqueue [Operation] so callers
* that must guarantee the WorkSpec is durably persisted before killing the process (the app-lock
* recovery restart) can await it — WorkManager persists the WorkSpec asynchronously on its serial
* task executor, so a fire-and-forget enqueue can be lost to a racing process death.
*/
fun syncNow(): Operation {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(networkConstraint)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request)
return workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request)
}
/**
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.restart
import android.content.Context
import android.content.Intent
import android.os.Process
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Relaunches the whole app in a brand-new process by handing off to [RestartActivity], a trampoline
* that runs in the separate `:restart` process. Because the trampoline survives the current process
* being killed, the relaunch it issues cannot be dropped by ActivityManager scheduling it into the
* dying process — the failure mode of a same-process "startActivity then exit(0)" restart.
*
* Used by the app-lock key-invalidation recovery to bounce the process so the cache is wiped safely at
* the next cold start (before Room reopens it).
*/
@Singleton
class ProcessRestarter @Inject constructor(@ApplicationContext private val context: Context) {
/**
* Start the [RestartActivity] trampoline in the `:restart` process, passing it this (main) process
* PID so it can kill us and relaunch from the outside. Returns immediately; the actual kill +
* relaunch happens in the trampoline process moments later.
*/
fun restart() {
val trampoline = Intent(context, RestartActivity::class.java).apply {
// Required because we may be started from a non-Activity (Application) context.
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(RestartActivity.EXTRA_ORIGINAL_PID, Process.myPid())
}
context.startActivity(trampoline)
}
companion object {
/**
* The `android:process` suffix of [RestartActivity] (must match AndroidManifest.xml). The
* Application uses it to skip its normal startup work when it is spun up in this aux process.
*/
const val PROCESS_SUFFIX = ":restart"
}
}
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.restart
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.os.Process
import android.util.Log
/**
* Separate-process trampoline that performs an app relaunch from OUTSIDE the process being killed.
*
* Declared in the manifest with `android:process=":restart"`, so Android runs it in its own process.
* That is the whole point: it kills the original (main) process by PID and only THEN starts the main
* launcher activity, so the relaunch is scheduled from a process that is NOT the one being torn down.
* A same-process "startActivity then Runtime.exit(0)" restart races ActivityManager — the relaunch can
* be scheduled into the dying process and silently dropped, so the app just closes. Issuing it from a
* surviving process (the ProcessPhoenix pattern) makes the relaunch reliable.
*
* DEVICE-ONLY: the multi-process kill/relaunch cannot be exercised in JVM unit tests; see
* [ProcessRestarter] for the (testable) intent/targeting seam and AppLockViewModelTest for the
* ordering guarantees around it.
*/
class RestartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Kill the original main process FIRST so the relaunch below spins up a genuinely fresh
// process — one whose cold DatabaseModule performs the pending cache wipe before Room opens.
// If we relaunched while the old process were still alive, ActivityManager could route the
// launch back into it and the wipe-on-cold-start would never run.
val originalPid = intent.getIntExtra(EXTRA_ORIGINAL_PID, INVALID_PID)
if (originalPid > INVALID_PID && originalPid != Process.myPid()) {
Process.killProcess(originalPid)
}
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (launchIntent != null) {
startActivity(launchIntent)
} else {
Log.w(TAG, "no launch intent for $packageName; cannot relaunch after restart")
}
finish()
// Tear down this trampoline process too: its only job was to issue the relaunch from outside
// the dying main process.
Runtime.getRuntime().exit(0)
}
companion object {
/** Extra carrying the PID of the main process to kill, so the relaunch starts a fresh one. */
const val EXTRA_ORIGINAL_PID = "org.libremail.restart.ORIGINAL_PID"
private const val INVALID_PID = -1
private const val TAG = "LibreMailRestart"
}
}
@@ -2,16 +2,18 @@
package org.libremail.ui.lock
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.UserNotAuthenticatedException
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.Operation
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -30,6 +32,10 @@ import org.libremail.data.security.LockState
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.restart.ProcessRestarter
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import javax.inject.Inject
/** UI state of the app-lock gate that wraps the whole app. */
@@ -73,6 +79,10 @@ class AppLockViewModel @Inject constructor(
private val databaseKeyCipher: DatabaseKeyCipher,
private val session: PassphraseSession,
private val syncScheduler: SyncScheduler,
// Issues the key-invalidation recovery relaunch from a separate ":restart" process that survives
// this process being killed, so the relaunch can't be dropped by ActivityManager scheduling it
// into the dying process (the same-process "startActivity then exit(0)" race).
private val processRestarter: ProcessRestarter,
// Application-scoped (see SecurityModule): the gate is injected rather than owned by this
// Activity-scoped ViewModel so the inactivity grace window survives Activity recreation — Back on
// the task root finishes the Activity and clears its ViewModelStore on API 29/30, which would
@@ -83,6 +93,12 @@ class AppLockViewModel @Inject constructor(
private val _uiState = MutableStateFlow<AppLockUiState>(AppLockUiState.Checking)
val uiState: StateFlow<AppLockUiState> = _uiState.asStateFlow()
// The dispatcher for blocking Keystore/DataStore/WorkManager work pushed off the main thread.
// Injectable so the recovery flow (clearCacheAndRestart) runs on the test scheduler and its
// ordering — enqueue durably persisted BEFORE the restart — is deterministically verifiable.
@VisibleForTesting
internal var defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
// Cached so onBackground / onForeground can cover the content synchronously (before the async
// settings read) whenever app-lock is on — so no stale mailbox frame renders on resume.
@Volatile private var appLockEnabledCached = false
@@ -125,7 +141,7 @@ class AppLockViewModel @Inject constructor(
// App-lock is on: cover any showing content while we resolve, so no stale mailbox frame
// renders before the (async) decision lands.
if (_uiState.value == AppLockUiState.Unlocked) _uiState.value = AppLockUiState.Checking
val action = withContext(Dispatchers.Default) {
val action = withContext(defaultDispatcher) {
KeyInvalidationPolicy.decide(
appLockEnabled = true,
encryptCacheEnabled = settings.encryptCache,
@@ -173,7 +189,7 @@ class AppLockViewModel @Inject constructor(
/** Called by the host after a successful `BiometricPrompt`. */
fun onAuthenticated() {
viewModelScope.launch {
when (withContext(Dispatchers.Default) { unlockOrArm() }) {
when (withContext(defaultDispatcher) { unlockOrArm() }) {
UnlockResult.OK -> {
gate.onAuthenticated()
publish()
@@ -258,25 +274,52 @@ class AppLockViewModel @Inject constructor(
}
private suspend fun clearCacheAndRestart(disableAppLock: Boolean) {
withContext(Dispatchers.Default) {
withContext(defaultDispatcher) {
// Record the wipe intent BEFORE flipping app-lock off, so a crash between the two writes
// leaves the wipe still pending (recoverable) rather than a disabled gate over a stale key.
// Both are DataStore edits that only return once durably committed, so they survive the
// restart below without further ceremony.
databaseKeyStore.setClearPending()
if (disableAppLock) settingsRepository.setAppLock(false)
syncScheduler.syncNow() // persisted by WorkManager; survives the restart
// Enqueue the post-wipe re-sync and BLOCK until WorkManager has durably persisted its
// WorkSpec before we hand off to the restart. syncNow() only *schedules* the insert on
// WorkManager's serial task executor; killing the process (via restartProcess) can race
// that async insert and drop the re-sync, leaving an empty mailbox after the wipe until the
// next periodic sync. Awaiting the enqueue Operation makes "survives the restart" real.
awaitSyncEnqueue(syncScheduler.syncNow())
}
restartProcess()
}
/**
* Block until WorkManager confirms the re-sync WorkSpec is durably persisted, bounded by
* [SYNC_ENQUEUE_TIMEOUT_SECONDS] so a stuck insert can never wedge recovery. A timeout/failure is
* logged and we restart anyway: the periodic sync will still eventually refill the wiped cache, so
* a best-effort wait is strictly better than the previous fire-and-forget enqueue. Runs on
* [defaultDispatcher] (never the main thread) because [Operation.result]'s get blocks.
*/
private fun awaitSyncEnqueue(operation: Operation) {
try {
operation.result.get(SYNC_ENQUEUE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (e: TimeoutException) {
Log.w(TAG, "re-sync enqueue not confirmed within timeout; restarting anyway", e)
} catch (e: ExecutionException) {
Log.w(TAG, "re-sync enqueue failed; restarting anyway", e)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
Log.w(TAG, "interrupted awaiting re-sync enqueue; restarting anyway", e)
}
}
/**
* Relaunch the app in a fresh process so [org.libremail.di.DatabaseModule] wipes the cache before
* Room reopens it. DEVICE-ONLY: process restart cannot be exercised in JVM unit tests.
* Room reopens it. Delegates to [ProcessRestarter], which issues the relaunch from a separate
* process that survives this one being killed — a same-process "startActivity then exit(0)" is
* unreliable because ActivityManager may schedule the relaunch into the dying process and drop it.
* DEVICE-ONLY end to end: the multi-process kill/relaunch cannot be exercised in JVM unit tests.
*/
private fun restartProcess() {
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (intent != null) context.startActivity(intent)
Runtime.getRuntime().exit(0)
processRestarter.restart()
}
// Monotonic clock so a wall-clock change can't extend the inactivity grace window.
@@ -284,5 +327,10 @@ class AppLockViewModel @Inject constructor(
private companion object {
const val TAG = "LibreMailAppLock"
// Upper bound on waiting for WorkManager to persist the re-sync WorkSpec. The insert is
// normally sub-second; this only caps a pathological stall so recovery can't hang before the
// restart. On timeout we restart anyway (the periodic sync still refills the cache later).
const val SYNC_ENQUEUE_TIMEOUT_SECONDS = 5L
}
}
@@ -4,12 +4,15 @@ package org.libremail.data.sync
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.Operation
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import javax.inject.Provider
import kotlin.test.assertSame
/**
* The enqueue methods are thin wrappers over WorkManager, so these tests pin the one thing that carries
@@ -79,6 +82,18 @@ class SyncSchedulerTest {
}
}
// The app-lock key-invalidation recovery restart awaits this Operation before killing the process
// (see AppLockViewModel), so the WorkSpec is durably persisted and the post-wipe re-sync survives.
@Test
fun `syncNow returns the enqueue operation so callers can await durable persistence`() {
val operation = mockk<Operation>()
every {
workManager.enqueueUniqueWork(any<String>(), any<ExistingWorkPolicy>(), any<OneTimeWorkRequest>())
} returns operation
assertSame(operation, scheduler.syncNow())
}
@Test
fun `backfillNow keeps an already-running backfill`() {
scheduler.backfillNow()
@@ -2,8 +2,13 @@
package org.libremail.ui.lock
import android.os.SystemClock
import android.util.Log
import androidx.work.Operation
import com.google.common.util.concurrent.ListenableFuture
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
@@ -11,6 +16,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
@@ -18,8 +24,14 @@ import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.security.AppLockGate
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.security.KeyInvalidationPolicy
import org.libremail.data.security.LockAction
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.restart.ProcessRestarter
import java.util.concurrent.TimeoutException
import kotlin.test.assertEquals
import kotlin.test.assertIs
@@ -29,6 +41,11 @@ import kotlin.test.assertIs
* itself (which Back on the task root would drop on API 29/30). These tests exercise the synchronous
* paths that delegate to the injected gate; the grace math itself is covered exhaustively — and
* deterministically — by AppLockGateTest. Broader ViewModel coverage is issue #100.
*
* The recovery-restart tests (#99) pin the ordering that makes the key-invalidation "clear + re-sync"
* safe: the re-sync enqueue must be durably persisted (its WorkManager Operation awaited) BEFORE the
* process is restarted, and a stuck enqueue must never wedge recovery. The separate-process relaunch
* itself is device-only; here we assert the ViewModel's orchestration around ProcessRestarter.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AppLockViewModelTest {
@@ -44,19 +61,27 @@ class AppLockViewModelTest {
unmockkAll()
}
private fun viewModel(gate: AppLockGate, appLock: Boolean = true): AppLockViewModel {
val settings = mockk<SettingsRepository>()
every { settings.settings } returns flowOf(AppSettings(appLock = appLock))
private fun viewModel(
gate: AppLockGate,
appLock: Boolean = true,
settingsRepository: SettingsRepository = mockk(relaxed = true),
databaseKeyStore: DatabaseKeyStore = mockk(relaxed = true),
syncScheduler: SyncScheduler = mockk(relaxed = true),
processRestarter: ProcessRestarter = mockk(relaxed = true),
): AppLockViewModel {
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = appLock))
return AppLockViewModel(
context = mockk(relaxed = true),
settingsRepository = settings,
settingsRepository = settingsRepository,
appLockManager = mockk(relaxed = true),
databaseKeyStore = mockk(relaxed = true),
databaseKeyStore = databaseKeyStore,
databaseKeyCipher = mockk(relaxed = true),
session = mockk(relaxed = true),
syncScheduler = mockk(relaxed = true),
syncScheduler = syncScheduler,
processRestarter = processRestarter,
gate = gate,
)
// Run the off-main recovery work on the test scheduler so its ordering is deterministic.
).also { it.defaultDispatcher = dispatcher }
}
@Test
@@ -84,4 +109,79 @@ class AppLockViewModelTest {
val state = assertIs<AppLockUiState.Locked>(vm.uiState.value)
assertEquals("boom", state.error)
}
@Test
fun `recovery persists the re-sync enqueue before restarting`() = runTest(dispatcher) {
val (future, syncScheduler) = enqueueingScheduler()
val databaseKeyStore = mockk<DatabaseKeyStore>(relaxed = true)
val processRestarter = mockk<ProcessRestarter>(relaxed = true)
val vm = clearOnForegroundViewModel(
databaseKeyStore = databaseKeyStore,
syncScheduler = syncScheduler,
processRestarter = processRestarter,
)
vm.onForeground()
advanceUntilIdle()
// The re-sync WorkSpec must be durably persisted (the enqueue Operation awaited) BEFORE the
// process is torn down. Otherwise WorkManager's async insert races the process death, the
// enqueue is lost, and the just-wiped cache never refills until the next periodic sync.
coVerifyOrder {
databaseKeyStore.setClearPending()
syncScheduler.syncNow()
future.get(any(), any())
processRestarter.restart()
}
}
@Test
fun `recovery still restarts when the re-sync enqueue await times out`() = runTest(dispatcher) {
val (future, syncScheduler) = enqueueingScheduler()
every { future.get(any(), any()) } throws TimeoutException("stuck insert")
val processRestarter = mockk<ProcessRestarter>(relaxed = true)
val vm = clearOnForegroundViewModel(syncScheduler = syncScheduler, processRestarter = processRestarter)
vm.onForeground()
advanceUntilIdle()
// A stuck WorkManager insert must not wedge recovery: the timeout is swallowed and we restart
// anyway (the periodic sync will still refill the wiped cache later).
verify { future.get(any(), any()) }
verify { processRestarter.restart() }
}
/** A [SyncScheduler] whose `syncNow()` returns an [Operation] whose result future can be stubbed. */
private fun enqueueingScheduler(): Pair<ListenableFuture<Operation.State.SUCCESS>, SyncScheduler> {
val future = mockk<ListenableFuture<Operation.State.SUCCESS>>()
every { future.get(any(), any()) } returns Operation.SUCCESS
val operation = mockk<Operation> { every { result } returns future }
val syncScheduler = mockk<SyncScheduler> { every { syncNow() } returns operation }
return future to syncScheduler
}
/**
* A ViewModel whose next `onForeground()` resolves to a cache-clear + restart: the pure decision
* table is stubbed to CLEAR_AND_REQUIRE_AUTH so the test drives the recovery path deterministically
* without reproducing the full key-invalidation device state (that logic is KeyInvalidationPolicyTest).
*/
private fun clearOnForegroundViewModel(
databaseKeyStore: DatabaseKeyStore = mockk(relaxed = true),
syncScheduler: SyncScheduler = mockk(relaxed = true),
processRestarter: ProcessRestarter = mockk(relaxed = true),
): AppLockViewModel {
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 1_000L
// android.util.Log is a no-op stub that throws "not mocked" in JVM tests; the timeout path logs.
mockkStatic(Log::class)
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
mockkObject(KeyInvalidationPolicy)
every { KeyInvalidationPolicy.decide(any(), any(), any(), any()) } returns LockAction.CLEAR_AND_REQUIRE_AUTH
return viewModel(
gate = mockk(relaxed = true),
databaseKeyStore = databaseKeyStore,
syncScheduler = syncScheduler,
processRestarter = processRestarter,
)
}
}