Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";

import { fontAssets } from "../theme/typography";
import { useTheme } from "../hooks/useTheme";
import { useInactivityLock } from "../hooks/useInactivityLock";
import { ConnectivityProvider, useConnectivity } from "../lib/connectivity";
import { WalletConnectApprovalModal } from "../components/WalletConnectApprovalModal";

Expand Down Expand Up @@ -35,6 +36,7 @@ export default function RootLayout() {
<SafeAreaProvider>
<ConnectivityProvider>
<ConnectivityGate />
<InactivityLockGate />
<Stack
screenOptions={{
headerShown: false,
Expand All @@ -52,6 +54,15 @@ export default function RootLayout() {
);
}

/**
* Arms the inactivity/background auto-lock. Rendered as a sibling of the
* navigator, like {@link ConnectivityGate}, so the hook can use the router.
*/
function InactivityLockGate() {
useInactivityLock();
return null;
}

/**
* Pushes the offline screen when connectivity drops and pops it again when it
* returns, so the route the user was on is preserved underneath. Rendered as a
Expand Down
148 changes: 148 additions & 0 deletions frontend/mobile/app/lock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import * as LocalAuthentication from 'expo-local-authentication';
import { useRouter } from 'expo-router';

import { useTheme } from '../hooks/useTheme';
import type { ThemeColors } from '../lib/theme';

/**
* Lock screen — the native port of the web wallet's `app/lock/page.tsx`.
*
* The wallet reaches here after an inactivity timeout or on returning from the
* background (see `hooks/useInactivityLock.ts`). Unlocking requires a real
* device biometric via `expo-local-authentication`
* (`authenticateAsync` prompts Face ID / fingerprint, falling back to the device
* passcode); on success we return to the dashboard.
*/
export default function LockScreen() {
const { colors } = useTheme();
const styles = useMemo(() => createStyles(colors), [colors]);
const router = useRouter();

const [isUnlocking, setIsUnlocking] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleUnlock = useCallback(async () => {
setError(null);
setIsUnlocking(true);
try {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!hasHardware || !isEnrolled) {
setError('No biometric or device passcode is set up. Add one in system settings.');
return;
}

const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Unlock Veil',
cancelLabel: 'Cancel',
// Allow the device passcode when biometrics fail, matching OS behaviour.
disableDeviceFallback: false,
});

if (result.success) {
router.replace('/');
return;
}
setError('Unlock failed. Please try again.');
} catch {
setError('Unlock failed. Please try again.');
} finally {
setIsUnlocking(false);
}
}, [router]);

// Prompt immediately on arrival so the user isn't stranded on a dead screen.
useEffect(() => {
void handleUnlock();
}, [handleUnlock]);

return (
<View style={styles.container}>
<View style={styles.iconCircle}>
<Text style={styles.iconGlyph}>🔒</Text>
</View>

<View style={styles.copy}>
<Text style={styles.title}>Wallet locked</Text>
<Text style={styles.subtitle}>Unlock with your biometric to continue.</Text>
</View>

{error && <Text style={styles.error}>{error}</Text>}

<Pressable
accessibilityRole="button"
onPress={handleUnlock}
disabled={isUnlocking}
style={({ pressed }) => [styles.button, (pressed || isUnlocking) && styles.buttonPressed]}
>
{isUnlocking ? (
<ActivityIndicator color={colors.onAccent} />
) : (
<Text style={styles.buttonLabel}>Unlock</Text>
)}
</Pressable>
</View>
);
}

const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
padding: 32,
gap: 28,
},
iconCircle: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
iconGlyph: {
fontSize: 30,
},
copy: {
alignItems: 'center',
gap: 6,
},
title: {
color: colors.textStrong,
fontSize: 22,
fontWeight: '700',
},
subtitle: {
color: colors.textSecondary,
fontSize: 15,
textAlign: 'center',
},
error: {
color: colors.danger,
fontSize: 14,
textAlign: 'center',
},
button: {
alignSelf: 'stretch',
maxWidth: 320,
backgroundColor: colors.accent,
borderRadius: 999,
paddingVertical: 14,
alignItems: 'center',
},
buttonPressed: {
opacity: 0.75,
},
buttonLabel: {
color: colors.onAccent,
fontSize: 16,
fontWeight: '700',
},
});
50 changes: 50 additions & 0 deletions frontend/mobile/hooks/useInactivityLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { useRouter, useSegments } from 'expo-router';

import { createIdleTimer } from '../lib/idleLock';

/**
* Locks the wallet after inactivity or when the app is backgrounded, so a lost
* or borrowed phone doesn't expose funds. The native port of the web wallet's
* `hooks/useInactivityLock.ts`.
*
* The countdown lives in `lib/idleLock.ts`; this hook wires it to React Native's
* `AppState` and expo-router. Sending the app to the background locks it
* immediately; returning to the foreground restarts the idle countdown. Either
* trigger routes to `/lock`, which re-prompts a biometric. It re-arms itself off
* the current route so it never fights the lock screen it just pushed.
*
* Mount once at the app root (alongside the connectivity gate in `_layout.tsx`).
*/
export function useInactivityLock(): void {
const router = useRouter();
const segments = useSegments();
const onLockRoute = segments[0] === 'lock';

useEffect(() => {
// Already locked — don't re-arm on top of the lock screen.
if (onLockRoute) return;

const lock = () => router.replace('/lock');
const timer = createIdleTimer({ onLock: lock });
timer.reset();

const subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'active') {
// Foregrounded: restart the idle countdown.
timer.reset();
} else if (state === 'background') {
// Backgrounded: lock now so returning requires a biometric. ('inactive'
// is transient — a notification shade or call sheet — and is ignored.)
timer.stop();
lock();
}
});

return () => {
timer.stop();
subscription.remove();
};
}, [router, onLockRoute]);
}
136 changes: 136 additions & 0 deletions frontend/mobile/lib/__tests__/idleLock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Tests for the inactivity auto-lock countdown. The timer is pure — its clock is
* injected — so the "locks after timeout", "reset postpones the lock", "never
* disabled", and "deferred while busy" paths all resolve deterministically
* without real time or React Native's AppState.
*/

import { createIdleTimer, idleTimeoutToMs, DEFAULT_IDLE_TIMEOUT } from '../idleLock';

/** A controllable fake clock: `advance` fires any timers due at or before `t`. */
function fakeClock() {
let now = 0;
let seq = 0;
const pending = new Map<number, { at: number; fn: () => void }>();

const setTimeoutFn = (fn: () => void, ms: number) => {
const id = ++seq;
pending.set(id, { at: now + ms, fn });
return id as unknown as ReturnType<typeof setTimeout>;
};
const clearTimeoutFn = (id: ReturnType<typeof setTimeout>) => {
pending.delete(id as unknown as number);
};
const advance = (ms: number) => {
now += ms;
for (const [id, t] of [...pending]) {
if (t.at <= now) {
pending.delete(id);
t.fn();
}
}
};
return { setTimeoutFn, clearTimeoutFn, advance, get size() { return pending.size; } };
}

describe('idleTimeoutToMs', () => {
it('converts minute options to milliseconds and never to null', () => {
expect(idleTimeoutToMs(5)).toBe(5 * 60_000);
expect(idleTimeoutToMs(30)).toBe(30 * 60_000);
expect(idleTimeoutToMs('never')).toBeNull();
expect(idleTimeoutToMs(DEFAULT_IDLE_TIMEOUT)).toBe(5 * 60_000);
});
});

describe('createIdleTimer', () => {
it('fires onLock once the timeout elapses', () => {
const clock = fakeClock();
const onLock = jest.fn();
const timer = createIdleTimer({
onLock,
getTimeoutMs: () => 1_000,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});

timer.reset();
clock.advance(999);
expect(onLock).not.toHaveBeenCalled();
clock.advance(1);
expect(onLock).toHaveBeenCalledTimes(1);
});

it('reset restarts the countdown, postponing the lock', () => {
const clock = fakeClock();
const onLock = jest.fn();
const timer = createIdleTimer({
onLock,
getTimeoutMs: () => 1_000,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});

timer.reset();
clock.advance(800);
timer.reset(); // activity before the deadline
clock.advance(800);
expect(onLock).not.toHaveBeenCalled();
clock.advance(200);
expect(onLock).toHaveBeenCalledTimes(1);
});

it('stop cancels a pending lock', () => {
const clock = fakeClock();
const onLock = jest.fn();
const timer = createIdleTimer({
onLock,
getTimeoutMs: () => 1_000,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});

timer.reset();
timer.stop();
clock.advance(5_000);
expect(onLock).not.toHaveBeenCalled();
});

it('never arms when the timeout is disabled', () => {
const clock = fakeClock();
const onLock = jest.fn();
const timer = createIdleTimer({
onLock,
getTimeoutMs: () => null,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});

timer.reset();
expect(clock.size).toBe(0);
clock.advance(1_000_000);
expect(onLock).not.toHaveBeenCalled();
});

it('defers the lock while shouldDefer holds, then locks once clear', () => {
const clock = fakeClock();
const onLock = jest.fn();
let busy = true;
const timer = createIdleTimer({
onLock,
getTimeoutMs: () => 1_000,
shouldDefer: () => busy,
deferMs: 500,
setTimeoutFn: clock.setTimeoutFn,
clearTimeoutFn: clock.clearTimeoutFn,
});

timer.reset();
clock.advance(1_000); // deadline hit, but busy → rescheduled
expect(onLock).not.toHaveBeenCalled();
clock.advance(500); // still busy → rescheduled again
expect(onLock).not.toHaveBeenCalled();
busy = false;
clock.advance(500);
expect(onLock).toHaveBeenCalledTimes(1);
});
});
Loading