import { useCallback, useEffect, useRef } from "react"; import { AppState as RNAppState, Platform } from "react-native"; const DEFAULT_IDLE_LOCK_MS = 10 * 60 * 1000; /** How often the suspend detector below checks in. */ const HEARTBEAT_MS = 1000; /** A gap this large between checks means the app was not running. */ const SUSPEND_GAP_MS = 4000; export interface UseAutoLockOptions { /** * Reports whether the session is currently "unlocked" (vault-style * apps) or any other state where the auto-lock should still fire. * Returning `false` short-circuits the lock call. */ isUnlocked: () => boolean; /** Called when the lock should fire. App owns side-effects. */ onLock: () => void; /** Reports whether auto-lock is enabled for this user (settings flag). */ isEnabled?: () => boolean | Promise; /** Idle threshold in milliseconds. Defaults to 10 minutes. */ idleLockMs?: number; } export interface UseAutoLockResult { /** * Call on any real user interaction to reset the idle clock. Required * on native: unlike web (which listens on `window` for * mousemove/touchstart/scroll/keydown directly), RN has no global * "any touch happened anywhere" event — a touch inside a `Pressable`, * `ScrollView`, etc. never reaches `window`, because there is no * `window` to reach. Without a caller wiring this up (e.g. a capture- * phase `onStartShouldSetResponderCapture` at the app root, returning * `false` so it never actually claims the touch), the idle timer is * armed once at mount and never reset — the session locks exactly * `idleLockMs` after mount/foreground REGARDLESS of how actively the * app is being used. Confirmed for real: a vault mid-active-use lost * its key mid-session, and every item silently failed to decrypt * (`Promise.allSettled` in the caller's list-fetch treats a decrypt * failure as "drop this item", not "the vault relocked"), which reads * to the user as vault items simply vanishing — no error, no visible * cause, on 2026-08-17. */ notifyActivity: () => void; } /** * Auto-lock a session after a period of inactivity. Generic over the * lock predicate — vault wires `KeyManager.isUnlocked` / `KeyManager.lock`, * other apps wire their own state. Web uses an idle-timer only; native * additionally locks immediately when the app goes to the background. */ export function useAutoLock(options: UseAutoLockOptions): UseAutoLockResult { const { isUnlocked, onLock, isEnabled, idleLockMs = DEFAULT_IDLE_LOCK_MS } = options; const onActivityRef = useRef<() => void>(() => {}); useEffect(() => { let idleTimer: ReturnType | null = null; let lastActivityAt = Date.now(); const lockIfEnabled = () => { if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null; } if (!isUnlocked()) { return; } Promise.resolve(isEnabled ? isEnabled() : true) .then((enabled) => { if (enabled && isUnlocked()) { onLock(); } }) .catch(() => {}); }; const armIdleTimer = () => { if (idleTimer !== null) { clearTimeout(idleTimer); } const remaining = idleLockMs - (Date.now() - lastActivityAt); if (remaining <= 0) { lockIfEnabled(); return; } idleTimer = setTimeout(lockIfEnabled, remaining); }; const onActivity = () => { lastActivityAt = Date.now(); armIdleTimer(); }; onActivityRef.current = onActivity; /** * Leaving the app, by any of the routes the OS calls leaving. * * 'background' alone was not enough. Locking the phone with the app open * and then unlocking the phone came back to an unlocked vault, because the * event that arrives for a screen-off is not reliably 'background' — RN * documents 'inactive' as iOS-only, and what a given Android version emits * when the keyguard comes up is not something to bet a password manager on. * * So both count, and — more importantly — the return is checked too. See * below: 'active' always arrives, whatever was or was not emitted on the * way out, which makes it the one signal that cannot be missed. */ let awaySince: number | null = null; // On web, RN Web's AppState mirrors document visibility — tab-hidden // becomes 'background', which would otherwise fire an immediate lock. // Only treat leaving as a lock signal on real native platforms. const subscription = RNAppState.addEventListener("change", (nextState) => { const native = Platform.OS !== "web"; if (native && (nextState === "background" || nextState === "inactive")) { if (awaySince === null) { awaySince = Date.now(); } lockIfEnabled(); } else if (nextState === "active") { // The backstop. If the departure was missed, or was suppressed by an // app-initiated system prompt that has since ended, this is where it // gets caught — the vault must never be found open after the app was // away, and coming back is the last moment to make that true. const wasAway = awaySince !== null; awaySince = null; if (native && wasAway) { lockIfEnabled(); } armIdleTimer(); } }); /** * The backstop that does not involve AppState at all. * * AppState was the only way this hook knew the app had gone away, and on a * real device it was reported not firing for either a minimise or a screen * lock — the vault was still open on return, with the setting on. Rather * than keep guessing which event a given platform version emits, this * measures the thing that actually matters: a suspended app's timers stop. * * The interval ticks every second. If more than `SUSPEND_GAP_MS` of wall * clock passed since the last tick, the app was not running, whatever the * OS did or did not tell us — so lock. * * The threshold is well above any plausible stall on the JS thread, which * is the one other thing that can delay an interval. Four seconds is far * more than the heaviest work this app does in one go (opening a vault is * milliseconds now that the crypto is native) and still short enough that * putting a phone down and picking it up again lands on a locked vault. */ let lastTick = Date.now(); const heartbeat = setInterval(() => { const now = Date.now(); const gap = now - lastTick; lastTick = now; if (Platform.OS !== "web" && gap > SUSPEND_GAP_MS) { lockIfEnabled(); } }, HEARTBEAT_MS); const cleanups: (() => void)[] = []; if (Platform.OS === "web" && typeof document !== "undefined") { const onVisibility = () => { if (document.visibilityState === "visible") { armIdleTimer(); } }; document.addEventListener("visibilitychange", onVisibility); cleanups.push(() => document.removeEventListener("visibilitychange", onVisibility)); const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart", "scroll"] as const; for (const e of activityEvents) { window.addEventListener(e, onActivity, { passive: true }); cleanups.push(() => window.removeEventListener(e, onActivity)); } } armIdleTimer(); return () => { subscription.remove(); clearInterval(heartbeat); for (const fn of cleanups) { fn(); } if (idleTimer !== null) { clearTimeout(idleTimer); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const notifyActivity = useCallback(() => { onActivityRef.current(); }, []); return { notifyActivity }; }