Skip to content

feat(mobile): security settings for lock timeout and biometric unlock - #551

Merged
Miracle656 merged 3 commits into
Miracle656:mainfrom
Elizabethxxx:feat/mobile-security-settings
Jul 30, 2026
Merged

feat(mobile): security settings for lock timeout and biometric unlock#551
Miracle656 merged 3 commits into
Miracle656:mainfrom
Elizabethxxx:feat/mobile-security-settings

Conversation

@Elizabethxxx

Copy link
Copy Markdown
Contributor

Lets users tune the app-lock behaviour: the inactivity timeout and whether unlocking requires biometrics.

closes #491

What is here

Acceptance: changing the timeout changes lock behaviour

A running watcher subscribes to the settings store, so a change reschedules it immediately rather than taking effect at the next lock. Both directions are covered:

  • Lengthening 5 → 30 minutes four minutes in: the old deadline passes without a lock, the new one fires.
  • Shortening 30 → 5 minutes six minutes in: six minutes idle already exceeds the new policy, so the lock is due at once rather than at the old deadline.
  • Switching to "never" cancels a pending lock outright.

Design notes

The timeout shares the web wallet's storage key (veil_idle_lock_minutes) and offers the same four options. The setting is shared across clients, so an option that existed on only one of them would be silently rewritten by the other.

Backgrounding is not activity. The web wallet treats visibilitychange as activity and resets the timer. On a phone the equivalent means something stronger: JS timers are frozen or killed while the app is away, so a timer alone would let a phone sit in a pocket for an hour and come back unlocked. The watcher records when the app left the foreground and, on return, locks if the idle period already elapsed.

Activity is reported explicitly via noteActivity(), since React Native has no global mouse/keyboard/scroll stream to listen to the way window does.

The biometric requirement is stored as a preference, not gated on a capability check. Whether a device has usable biometrics can change between setting the toggle and the unlock, so the lock screen resolves that when it prompts and falls back to the passkey rather than locking a user out of their own funds. The screen says so.

Deferral is carried over from the web watcher: shouldDefer postpones a lock rather than interrupting an in-flight transaction.

Scope

This is the settings half of the lock system — it decides when a lock is due and how strong the unlock must be. Presenting the lock screen and performing the unlock is backlog #28, which consumes createIdleWatcher and getLockSettings rather than reimplementing either. That is why the behaviour is demonstrated by tests against the watcher instead of by a lock screen appearing.

Checks

npm run typecheck and npm test pass in frontend/mobile.

Adds /settings/security, where the user tunes the app-lock policy: how
long the app may sit idle before locking (5 / 15 / 30 minutes, or
never) and whether unlocking must present a biometric factor. The
timeout uses the same veil_idle_lock_minutes key as the web wallet, so
the choice carries between clients.

lib/appLock.ts holds the policy and the idle watcher the lock screen
(backlog Miracle656#28) consumes. Changing the timeout takes effect immediately
rather than at the next lock: watchers subscribe to the settings store
and reschedule, including firing straight away when a shortened timeout
leaves the deadline already in the past.

Two things differ from the web wallet's lib/idle-lock.ts. Activity is
reported explicitly through noteActivity(), because React Native has no
global mouse/keyboard event stream to listen to. And backgrounding is
not treated as activity the way visibilitychange is on the web -- JS
timers are frozen or killed while the app is away, so the watcher
records when it left the foreground and locks on return if the idle
period already elapsed. Without that, a phone could sit in a pocket for
an hour and come back unlocked.

The biometric requirement is stored as a preference rather than gated
on a capability check: whether the device has usable biometrics can
change between now and the unlock, so the lock screen resolves that
when it prompts and falls back to the passkey rather than locking the
user out of their funds.
@Elizabethxxx
Elizabethxxx requested a review from Miracle656 as a code owner July 28, 2026 20:18
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

@Elizabethxxx is attempting to deploy a commit to the miracle656's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jul 28, 2026

Copy link
Copy Markdown

@Elizabethxxx Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Two gaps that stopped the settings taking effect:

- hydrateLockSettings() was never called, so the app started on the defaults
  every launch and a saved timeout only applied once the user re-picked it.
  Called from app/_layout.tsx alongside hydrateNetwork().
- /settings/security was unreachable. The settings tab rendered its rows as
  Pressables that only set local state, so the "Security & lock" row went
  nowhere. Rows now take an optional href and navigate when present.

tsc clean; jest 15 suites / 266 tests; expo lint clean.
@Miracle656

Copy link
Copy Markdown
Owner

Merging. lib/appLock.ts follows the same shape as the network module — typed settings, a subscription so screens re-render, hydration from AsyncStorage — and 408 lines of tests around a 313-line module is a good ratio for something guarding funds. Modelling 'never' as a first-class LockTimeout variant rather than a magic 0 is the right call.

Two things had to be fixed for the settings to actually do anything:

hydrateLockSettings() was never called. Exactly the same gap #526 had with the network override: settings were written to AsyncStorage and a hydrate function existed, but nothing invoked it, so every launch started on DEFAULT_LOCK_TIMEOUT and a saved choice only applied once the user went back and re-picked it. Now called from app/_layout.tsx next to hydrateNetwork().

/settings/security was unreachable. The settings tab renders its rows as Pressables whose onPress only sets local state — there's a "Security & lock" row, but tapping it went nowhere. SettingsRow now takes an optional href and navigates when one is present, with that row pointing at the new screen. The other rows keep their existing behaviour until their own screens land.

Verified: tsc --noEmit clean, jest 15 suites / 266 tests, expo lint clean, CI green.

On the acceptance criterion — changing timeout changes lock behavior — the settings half is now complete and persisted, but the consumer isn't here yet: backlog #28's actual lock screen is #596, still open. So lockTimeoutToMs() currently has no caller enforcing it. Worth re-testing this end to end once #596 lands; the storage keys (veil_idle_lock_minutes, veil_lock_require_biometrics) and subscribeToLockSettings are the contract it should read from.

Also worth noting for whoever picks up #596: DEFAULT_REQUIRE_BIOMETRICS is false, so biometric unlock is opt-in. That's a defensible default for a wallet that already sits behind device unlock, but it's a product decision worth making deliberately rather than inheriting.

@Miracle656
Miracle656 merged commit 0866d24 into Miracle656:main Jul 30, 2026
10 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

63. Security settings

3 participants