Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'

git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
This commit is contained in:
2026-08-16 21:42:59 -04:00
222 changed files with 23436 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
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 { useAccounts } from "@/contexts/AccountsContext";
import {
clearStoredPin,
getAppLockEnabled,
getBiometricEnabled,
getStoredPin,
isValidPin,
setAppLockEnabled,
setBiometricEnabled,
setStoredPin,
} from "@/lib/app-lock";
import { hasPendingShortcut, subscribeShortcutQueue } from "@/lib/shortcut-queue";
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 { activeAccountId } = useAccounts();
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 biometricUnlockInProgress = useRef(false);
const hydrated = useRef(false);
useEffect(() => {
if (!activeAccountId) {
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
hydrated.current = false;
return;
}
let cancelled = false;
hydrated.current = false;
const accountId = activeAccountId;
async function hydrate() {
const [lockEnabled, pin, bioEnabled, hasHardware, isEnrolled, authTypes, shortcutPending] =
await Promise.all([
getAppLockEnabled(accountId),
getStoredPin(accountId),
getBiometricEnabled(accountId),
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
LocalAuthentication.supportedAuthenticationTypesAsync(),
hasPendingShortcut(),
]);
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 && !shortcutPending);
hydrated.current = true;
}
void hydrate();
return () => {
cancelled = true;
};
}, [activeAccountId]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (!hydrated.current || !enabled || !activeAccountId) return;
// Only true backgrounding should re-lock — `inactive` fires during Face ID,
// Control Center, and other system sheets and must not trigger another lock.
if (nextState === "background") {
wasBackgrounded.current = true;
}
if (
nextState === "active" &&
wasBackgrounded.current &&
!biometricUnlockInProgress.current
) {
wasBackgrounded.current = false;
void hasPendingShortcut().then((shortcutPending) => {
if (!shortcutPending) {
setIsLocked(true);
}
});
}
});
return () => subscription.remove();
}, [enabled, activeAccountId]);
useEffect(() => {
if (!enabled) return;
return subscribeShortcutQueue(() => {
void hasPendingShortcut().then((shortcutPending) => {
if (shortcutPending) {
setIsLocked(false);
}
});
});
}, [enabled]);
const unlockWithPin = useCallback(
async (pin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== pin) {
return false;
}
wasBackgrounded.current = false;
setIsLocked(false);
return true;
},
[activeAccountId],
);
const unlockWithBiometric = useCallback(async () => {
if (!biometricAvailable || !activeAccountId) {
return false;
}
biometricUnlockInProgress.current = true;
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN",
disableDeviceFallback: false,
biometricsSecurityLevel: "weak",
});
if (!result.success) {
return false;
}
if (!biometricEnabled) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
wasBackgrounded.current = false;
setIsLocked(false);
return true;
} finally {
biometricUnlockInProgress.current = false;
}
}, [biometricAvailable, biometricEnabled, activeAccountId]);
const enableLock = useCallback(
async (pin: string) => {
if (!activeAccountId) {
throw new Error("No active account");
}
if (!isValidPin(pin)) {
throw new Error("PIN must be 46 digits");
}
const [hasHardware, isEnrolled] = await Promise.all([
LocalAuthentication.hasHardwareAsync(),
LocalAuthentication.isEnrolledAsync(),
]);
const bioAvailable = hasHardware && isEnrolled;
await setStoredPin(activeAccountId, pin);
await setAppLockEnabled(activeAccountId, true);
setHasPin(true);
setEnabled(true);
setIsLocked(false);
if (bioAvailable) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
},
[activeAccountId],
);
const disableLock = useCallback(
async (pin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== pin) {
return false;
}
await setAppLockEnabled(activeAccountId, false);
await clearStoredPin(activeAccountId);
await setBiometricEnabled(activeAccountId, false);
setEnabled(false);
setHasPin(false);
setBiometricEnabledState(false);
setIsLocked(false);
return true;
},
[activeAccountId],
);
const changePin = useCallback(
async (currentPin: string, nextPin: string) => {
if (!activeAccountId) return false;
const stored = await getStoredPin(activeAccountId);
if (!stored || stored !== currentPin || !isValidPin(nextPin)) {
return false;
}
await setStoredPin(activeAccountId, nextPin);
return true;
},
[activeAccountId],
);
const setUseBiometric = useCallback(
async (next: boolean) => {
if (!activeAccountId) return;
if (next) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: `Enable ${biometricLabel}`,
cancelLabel: "Cancel",
disableDeviceFallback: true,
});
if (!result.success) return;
}
await setBiometricEnabled(activeAccountId, next);
setBiometricEnabledState(next);
},
[biometricLabel, activeAccountId],
);
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;
}