Files
beenvoice-app/contexts/AppLockContext.tsx
T
soconnor 14c880123c Add beenvoice mobile companion app with full dark mode support.
Expo app with dashboard, time clock, invoices, and settings — native tabs, glass UI, theme-aware components, and iOS Live Activities.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 22:36:37 -04:00

233 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as LocalAuthentication from "expo-local-authentication";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import {
clearStoredPin,
getAppLockEnabled,
getBiometricEnabled,
getStoredPin,
isValidPin,
setAppLockEnabled,
setBiometricEnabled,
setStoredPin,
} from "@/lib/app-lock";
type AppLockContextValue = {
enabled: boolean;
biometricEnabled: boolean;
hasPin: boolean;
isLocked: boolean;
biometricAvailable: boolean;
biometricLabel: string;
unlockWithPin: (pin: string) => Promise<boolean>;
unlockWithBiometric: () => Promise<boolean>;
enableLock: (pin: string) => Promise<void>;
disableLock: (pin: string) => Promise<boolean>;
changePin: (currentPin: string, nextPin: string) => Promise<boolean>;
setUseBiometric: (enabled: boolean) => Promise<void>;
lock: () => void;
};
const AppLockContext = createContext<AppLockContextValue | null>(null);
export function AppLockProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabled] = useState(false);
const [biometricEnabled, setBiometricEnabledState] = useState(false);
const [hasPin, setHasPin] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricLabel, setBiometricLabel] = useState("Biometrics");
const wasBackgrounded = useRef(false);
const hydrated = useRef(false);
useEffect(() => {
let cancelled = false;
async function hydrate() {
const [lockEnabled, pin, bioEnabled, hasHardware, isEnrolled, authTypes] =
await Promise.all([
getAppLockEnabled(),
getStoredPin(),
getBiometricEnabled(),
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
LocalAuthentication.supportedAuthenticationTypesAsync(),
]);
if (cancelled) return;
const bioAvailable = hasHardware && isEnrolled;
setEnabled(lockEnabled);
setHasPin(Boolean(pin));
setBiometricEnabledState(bioEnabled && bioAvailable);
setBiometricAvailable(bioAvailable);
setBiometricLabel(
authTypes.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)
? "Face ID"
: authTypes.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)
? "Touch ID"
: "Biometrics",
);
setIsLocked(lockEnabled);
hydrated.current = true;
}
void hydrate();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (!hydrated.current || !enabled) return;
if (nextState === "background" || nextState === "inactive") {
wasBackgrounded.current = true;
}
if (nextState === "active" && wasBackgrounded.current) {
wasBackgrounded.current = false;
setIsLocked(true);
}
});
return () => subscription.remove();
}, [enabled]);
const unlockWithPin = useCallback(async (pin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== pin) {
return false;
}
setIsLocked(false);
return true;
}, []);
const unlockWithBiometric = useCallback(async () => {
if (!biometricEnabled || !biometricAvailable) {
return false;
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN",
disableDeviceFallback: true,
});
if (!result.success) {
return false;
}
setIsLocked(false);
return true;
}, [biometricAvailable, biometricEnabled]);
const enableLock = useCallback(async (pin: string) => {
if (!isValidPin(pin)) {
throw new Error("PIN must be 46 digits");
}
await setStoredPin(pin);
await setAppLockEnabled(true);
setHasPin(true);
setEnabled(true);
setIsLocked(false);
}, []);
const disableLock = useCallback(async (pin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== pin) {
return false;
}
await setAppLockEnabled(false);
await clearStoredPin();
await setBiometricEnabled(false);
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
return true;
}, []);
const changePin = useCallback(async (currentPin: string, nextPin: string) => {
const stored = await getStoredPin();
if (!stored || stored !== currentPin || !isValidPin(nextPin)) {
return false;
}
await setStoredPin(nextPin);
return true;
}, []);
const setUseBiometric = useCallback(async (next: boolean) => {
if (next) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: `Enable ${biometricLabel}`,
cancelLabel: "Cancel",
disableDeviceFallback: true,
});
if (!result.success) return;
}
await setBiometricEnabled(next);
setBiometricEnabledState(next);
}, [biometricLabel]);
const lock = useCallback(() => {
if (enabled) {
setIsLocked(true);
}
}, [enabled]);
const value = useMemo(
() => ({
enabled,
biometricEnabled,
hasPin,
isLocked,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
}),
[
enabled,
biometricEnabled,
hasPin,
isLocked,
biometricAvailable,
biometricLabel,
unlockWithPin,
unlockWithBiometric,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
],
);
return <AppLockContext.Provider value={value}>{children}</AppLockContext.Provider>;
}
export function useAppLock() {
const context = useContext(AppLockContext);
if (!context) {
throw new Error("useAppLock must be used within AppLockProvider");
}
return context;
}