diff --git a/App.tsx b/App.tsx index ad146f1..c9c50a3 100644 --- a/App.tsx +++ b/App.tsx @@ -11,6 +11,7 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {ThemeProvider} from './src/context/ThemeContext'; import {DialogProvider} from './src/context/DialogContext'; +import {AppLockProvider} from './src/context/AppLockContext'; import {SheetProvider} from 'react-native-actions-sheet'; import ErrorBoundary from './src/components/atoms/ErrorBoundary'; import PrimaryText from './src/components/atoms/PrimaryText'; @@ -71,11 +72,13 @@ const App = () => { - - - - - + + + + + + + diff --git a/ios/zero/Info.plist b/ios/zero/Info.plist index 150356b..257139b 100644 --- a/ios/zero/Info.plist +++ b/ios/zero/Info.plist @@ -35,6 +35,8 @@ NSLocationWhenInUseUsageDescription + NSFaceIDUsageDescription + Zero uses Face ID to protect your financial data RCTNewArchEnabled UIAppFonts diff --git a/package.json b/package.json index ac6dd14..d3631d8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react-i18next": "^17.0.8", "react-native": "0.84.1", "react-native-actions-sheet": "^10.1.2", + "react-native-biometrics": "^3.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.30.0", "react-native-get-random-values": "^2.0.0", diff --git a/src/components/atoms/LockScreen.tsx b/src/components/atoms/LockScreen.tsx new file mode 100644 index 0000000..da19667 --- /dev/null +++ b/src/components/atoms/LockScreen.tsx @@ -0,0 +1,58 @@ +import React, {useEffect, useRef} from 'react'; +import {Animated, TouchableOpacity, View} from 'react-native'; +import {useTranslation} from 'react-i18next'; +import PrimaryView from './PrimaryView'; +import PrimaryText from './PrimaryText'; +import Icon from './Icons'; +import {useThemeColors} from '../../context/ThemeContext'; +import {gs} from '../../styles/globalStyles'; + +interface LockScreenProps { + onAuthenticate: () => void; +} + +const LockScreen: React.FC = ({onAuthenticate}) => { + const colors = useThemeColors(); + const {t} = useTranslation(); + const fadeAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + }, [fadeAnim]); + + return ( + + + + + + + zero + + + {t('settings.appLockPrompt')} + + + + + Tap to unlock + + + + + ); +}; + +export default LockScreen; diff --git a/src/context/AppLockContext.tsx b/src/context/AppLockContext.tsx new file mode 100644 index 0000000..9e52deb --- /dev/null +++ b/src/context/AppLockContext.tsx @@ -0,0 +1,161 @@ +import React, {createContext, useContext, useState, useEffect, useCallback, type ReactNode} from 'react'; +import {AppState, Modal} from 'react-native'; +import ReactNativeBiometrics from 'react-native-biometrics'; +import StorageService from '../utils/asyncStorageService'; +import LockScreen from '../components/atoms/LockScreen'; +import {useTranslation} from 'react-i18next'; +import {useSelector} from 'react-redux'; +import {selectIsOnboarded} from '../redux/slice/isOnboardedSlice'; + +interface AppLockContextType { + isAppLockEnabled: boolean; + isBiometricAvailable: boolean; + toggleAppLock: () => Promise; +} + +const AppLockContext = createContext(undefined); + +interface AppLockProviderProps { + children: ReactNode; +} + +const rnBiometrics = new ReactNativeBiometrics(); +const APP_LOCK_ENABLED_KEY = 'appLockEnabled'; + +export const AppLockProvider: React.FC = ({children}) => { + const {t} = useTranslation(); + const isOnboarded = useSelector(selectIsOnboarded); + + const [isAppLockEnabled, setIsAppLockEnabled] = useState(false); + const [isBiometricAvailable, setIsBiometricAvailable] = useState(false); + const [isLocked, setIsLocked] = useState(false); + const [appState, setAppState] = useState(AppState.currentState); + + // Initialize + useEffect(() => { + const checkAvailability = async () => { + try { + const {available} = await rnBiometrics.isSensorAvailable(); + setIsBiometricAvailable(available); + + // Read preference + const enabled = StorageService.getBoolean(APP_LOCK_ENABLED_KEY); + + if (enabled) { + if (available) { + setIsAppLockEnabled(true); + setIsLocked(true); // Lock on startup if enabled + } else { + // Graceful fallback: sensor no longer available, disable lock + StorageService.setBoolean(APP_LOCK_ENABLED_KEY, false); + setIsAppLockEnabled(false); + setIsLocked(false); + } + } + } catch (error) { + if (__DEV__) console.error('Biometrics check error:', error); + } + }; + + checkAvailability(); + }, []); + + const authenticate = useCallback(async () => { + if (!isBiometricAvailable) return false; + + try { + const {success} = await rnBiometrics.simplePrompt({ + promptMessage: t('settings.appLockPrompt') || 'Unlock Zero', + }); + + if (success) { + setIsLocked(false); + return true; + } + return false; + } catch (error) { + if (__DEV__) console.error('Authentication error:', error); + return false; + } + }, [isBiometricAvailable, t]); + + // Handle App State changes (background -> foreground) + useEffect(() => { + const subscription = AppState.addEventListener('change', nextAppState => { + if (appState.match(/inactive|background/) && nextAppState === 'active') { + if (isAppLockEnabled && isOnboarded) { + setIsLocked(true); + } + } + setAppState(nextAppState); + }); + + return () => { + subscription.remove(); + }; + }, [appState, isAppLockEnabled, isOnboarded]); + + // Auto-authenticate when locked screen appears + useEffect(() => { + let timer: ReturnType; + if (isLocked && isOnboarded) { + // Small timeout to ensure UI renders before native prompt + timer = setTimeout(() => { + void authenticate(); + }, 500); + } + return () => { + if (timer) clearTimeout(timer); + }; + }, [isLocked, isOnboarded, authenticate]); + + const toggleAppLock = useCallback(async () => { + if (!isBiometricAvailable) return false; + + if (isAppLockEnabled) { + // Disabling requires authentication + const success = await authenticate(); + if (success) { + StorageService.setBoolean(APP_LOCK_ENABLED_KEY, false); + setIsAppLockEnabled(false); + } + return !success; // return true if still enabled + } else { + // Enabling requires authentication to prove it works + const success = await authenticate(); + if (success) { + StorageService.setBoolean(APP_LOCK_ENABLED_KEY, true); + setIsAppLockEnabled(true); + } + return success; // return true if successfully enabled + } + }, [isAppLockEnabled, isBiometricAvailable, authenticate]); + + const value = { + isAppLockEnabled, + isBiometricAvailable, + toggleAppLock, + }; + + // Only show lock screen if user is onboarded + const showLockScreen = isLocked && isOnboarded; + + return ( + + {children} + + + + + ); +}; + +export const useAppLock = (): AppLockContextType => { + const context = useContext(AppLockContext); + if (context === undefined) { + throw new Error('useAppLock must be used within an AppLockProvider'); + } + return context; +}; + +export default AppLockContext; diff --git a/src/context/index.ts b/src/context/index.ts index fe4213c..3f0d308 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -8,3 +8,5 @@ export { } from './ThemeContext'; export {DialogProvider, useDialog} from './DialogContext'; + +export {AppLockProvider, useAppLock} from './AppLockContext'; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 62e2792..a40f45c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -197,7 +197,13 @@ "exportSuccess": "Your data is successfully exported in Downloads folder", "exportError": "There is an error in exporting your data", "storagePermission": "You need to manually give permission for the storage to download your data", - "language": "Language" + "language": "Language", + "appLock": "App Lock", + "appLockSubtitle": "Unlock with biometrics", + "appLockPrompt": "Unlock Zero", + "appLockNotAvailable": "Biometric authentication is not available on this device", + "appLockEnabled": "App lock enabled", + "appLockDisabled": "App lock disabled" }, "sheets": { "selectTheme": "Select Theme", diff --git a/src/screens/SettingsScreen/index.tsx b/src/screens/SettingsScreen/index.tsx index f31601d..65450cb 100644 --- a/src/screens/SettingsScreen/index.tsx +++ b/src/screens/SettingsScreen/index.tsx @@ -71,6 +71,9 @@ const SettingsScreen = () => { allData, handleExportResult, requestStorageViaDialog, + isAppLockEnabled, + isBiometricAvailable, + toggleAppLock, } = useSettings(); const handleOpenCurrencySheet = useCallback(() => { @@ -209,6 +212,27 @@ const SettingsScreen = () => { }); }} /> + {isBiometricAvailable ? ( + <> + + toggleAppLock()} + valueNode={ + + } + /> + + ) : null} { const allData = useSelector(selectAllData); const {colors, themeMode, setThemeMode} = useTheme(); + const {isAppLockEnabled, isBiometricAvailable, toggleAppLock} = useAppLock(); const {showDialog, showAlert} = useDialog(); const appVersion = getAppVersion(); @@ -201,6 +203,9 @@ const useSettings = () => { allData, handleExportResult, requestStorageViaDialog, + isAppLockEnabled, + isBiometricAvailable, + toggleAppLock, }; };