Skip to content
Merged
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 @@ -10,6 +10,7 @@ import { StyleSheet } from 'react-native';

import { fontAssets } from '../theme/typography';
import { useTheme } from '../hooks/useTheme';
import { useInactivityLock } from '../hooks/useInactivityLock';
import { ConnectivityProvider, useConnectivity } from '../lib/connectivity';
import { hydrateNetwork } from '../lib/network';
import { hydrateLockSettings } from '../lib/appLock';
Expand Down Expand Up @@ -54,6 +55,7 @@ export default function RootLayout() {
<BottomSheetModalProvider>
<ConnectivityProvider>
<ConnectivityGate />
<InactivityLockGate />
<Stack
screenOptions={{
headerShown: false,
Expand All @@ -77,6 +79,15 @@ const styles = StyleSheet.create({
root: { flex: 1 },
});

/**
* 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
186 changes: 141 additions & 45 deletions frontend/mobile/app/lock.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,148 @@
import { ScreenScaffold, ComingSoonBadge, colors } from '@/components/ScreenScaffold';
import { View, Text, StyleSheet } from 'react-native';
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]);

export default function LockRoute() {
return (
<ScreenScaffold
eyebrow="Locked"
title="Unlock Veil"
description="Authenticate with your device passkey to continue."
backHref="/"
backLabel="Home"
>
<View style={styles.lockCircle}>
<Text style={styles.lockGlyph}>◉</Text>
<View style={styles.container}>
<View style={styles.iconCircle}>
<Text style={styles.iconGlyph}>🔒</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Why we lock</Text>
<Text style={styles.cardText}>
Wallet signs automatically lock when the app is backgrounded or after extended inactivity.
Approve the passkey prompt to resume your session.
</Text>

<View style={styles.copy}>
<Text style={styles.title}>Wallet locked</Text>
<Text style={styles.subtitle}>Unlock with your biometric to continue.</Text>
</View>
<ComingSoonBadge note="Passkey prompt wiring lands in the lock-screen issue" />
</ScreenScaffold>

{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 styles = StyleSheet.create({
lockCircle: {
alignSelf: 'center',
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: colors.surface,
borderColor: colors.gold,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
marginTop: 4,
},
lockGlyph: { color: colors.gold, fontSize: 38, lineHeight: 40 },
card: {
padding: 18,
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border,
gap: 8,
},
cardTitle: { color: colors.gold, fontSize: 12, fontWeight: '700', letterSpacing: 1.4, textTransform: 'uppercase' },
cardText: { color: colors.offWhite, fontSize: 14, lineHeight: 20 },
});
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',
},
});
45 changes: 45 additions & 0 deletions frontend/mobile/hooks/useInactivityLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { useRouter, useSegments } from 'expo-router';

import { createIdleWatcher } from '../lib/appLock';

/**
* Locks the wallet after inactivity or when the app is backgrounded, so a lost
* or borrowed phone doesn't expose funds.
*
* The countdown lives in `lib/appLock.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 watcher = createIdleWatcher({ onLock: lock });
watcher.start();

const subscription = AppState.addEventListener('change', (state) => {
if (state === 'background') {
// Backgrounded: lock now so returning requires a biometric.
watcher.stop();
lock();
}
});

return () => {
watcher.stop();
subscription.remove();
};
}, [router, onLockRoute]);
}
70 changes: 60 additions & 10 deletions frontend/mobile/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"expo-file-system": "~57.0.1",
"expo-font": "~57.0.1",
"expo-linking": "~57.0.4",
"expo-local-authentication": "~57.0.2",
"expo-router": "~57.0.8",
"expo-secure-store": "~57.0.1",
"expo-sharing": "~57.0.7",
Expand Down
Loading