Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -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 4–6 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;
|
||||
}
|
||||
Reference in New Issue
Block a user