Skip to content
Open
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
13 changes: 8 additions & 5 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,11 +72,13 @@ const App = () => {
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider>
<DialogProvider>
<SheetProvider>
<NavigationContainer ref={setNavigationRef}>
<MainStack />
</NavigationContainer>
</SheetProvider>
<AppLockProvider>
<SheetProvider>
<NavigationContainer ref={setNavigationRef}>
<MainStack />
</NavigationContainer>
</SheetProvider>
</AppLockProvider>
</DialogProvider>
</ThemeProvider>
</PersistGate>
Expand Down
2 changes: 2 additions & 0 deletions ios/zero/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string/>
<key>NSFaceIDUsageDescription</key>
<string>Zero uses Face ID to protect your financial data</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIAppFonts</key>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions src/components/atoms/LockScreen.tsx
Original file line number Diff line number Diff line change
@@ -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<LockScreenProps> = ({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 (
<PrimaryView colors={colors} style={gs.center}>
<Animated.View style={[gs.itemsCenter, {opacity: fadeAnim}]}>
<View style={[gs.size100, gs.roundedFull, gs.center, gs.mb20, {backgroundColor: colors.secondaryAccent}]}>
<Icon name="fingerprint" size={50} color={colors.accentGreen} />
</View>
<PrimaryText size={40} weight="bold" color={colors.primaryText}>
zero
</PrimaryText>
<PrimaryText size={16} color={colors.secondaryText} style={gs.mt10}>
{t('settings.appLockPrompt')}
</PrimaryText>

<TouchableOpacity
onPress={onAuthenticate}
style={[
gs.mt30,
gs.py12,
gs.px20,
gs.rounded8,
{backgroundColor: colors.accentGreen},
]}>
<PrimaryText size={14} weight="semibold" color={colors.buttonText}>
Tap to unlock
</PrimaryText>
</TouchableOpacity>
</Animated.View>
</PrimaryView>
);
};

export default LockScreen;
161 changes: 161 additions & 0 deletions src/context/AppLockContext.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

const AppLockContext = createContext<AppLockContextType | undefined>(undefined);

interface AppLockProviderProps {
children: ReactNode;
}

const rnBiometrics = new ReactNativeBiometrics();
const APP_LOCK_ENABLED_KEY = 'appLockEnabled';

export const AppLockProvider: React.FC<AppLockProviderProps> = ({children}) => {
const {t} = useTranslation();
const isOnboarded = useSelector(selectIsOnboarded);

const [isAppLockEnabled, setIsAppLockEnabled] = useState<boolean>(false);
const [isBiometricAvailable, setIsBiometricAvailable] = useState<boolean>(false);
const [isLocked, setIsLocked] = useState<boolean>(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<typeof setTimeout>;
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 (
<AppLockContext.Provider value={value}>
{children}
<Modal visible={showLockScreen} animationType="fade" transparent={false} statusBarTranslucent>
<LockScreen onAuthenticate={authenticate} />
</Modal>
</AppLockContext.Provider>
);
};

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;
2 changes: 2 additions & 0 deletions src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export {
} from './ThemeContext';

export {DialogProvider, useDialog} from './DialogContext';

export {AppLockProvider, useAppLock} from './AppLockContext';
8 changes: 7 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions src/screens/SettingsScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ const SettingsScreen = () => {
allData,
handleExportResult,
requestStorageViaDialog,
isAppLockEnabled,
isBiometricAvailable,
toggleAppLock,
} = useSettings();

const handleOpenCurrencySheet = useCallback(() => {
Expand Down Expand Up @@ -209,6 +212,27 @@ const SettingsScreen = () => {
});
}}
/>
{isBiometricAvailable ? (
<>
<View style={[gs.mx16, {height: 1, backgroundColor: colors.secondaryAccent}]} />
<SettingsRow
colors={colors}
icon="fingerprint"
label={t('settings.appLock')}
subtitle={t('settings.appLockSubtitle')}
onPress={() => toggleAppLock()}
valueNode={
<View
style={[
gs.size16,
gs.roundedFull,
{backgroundColor: isAppLockEnabled ? colors.accentGreen : colors.secondaryText},
]}
/>
}
/>
</>
) : null}
</View>

<PrimaryText
Expand Down
5 changes: 5 additions & 0 deletions src/screens/SettingsScreen/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {useCallback, useEffect} from 'react';
import {getAppVersion} from '../../utils/getVersion';
import {useTheme, ThemeMode} from '../../context/ThemeContext';
import {useAppLock} from '../../context';
import {useDialog} from '../../context/DialogContext';
import StorageService from '../../utils/asyncStorageService';
import {updateUserById, updateCurrencyById, deleteAllData} from '../../watermelondb/services';
Expand All @@ -29,6 +30,7 @@ const useSettings = () => {
const allData = useSelector(selectAllData);

const {colors, themeMode, setThemeMode} = useTheme();
const {isAppLockEnabled, isBiometricAvailable, toggleAppLock} = useAppLock();
const {showDialog, showAlert} = useDialog();
const appVersion = getAppVersion();

Expand Down Expand Up @@ -201,6 +203,9 @@ const useSettings = () => {
allData,
handleExportResult,
requestStorageViaDialog,
isAppLockEnabled,
isBiometricAvailable,
toggleAppLock,
};
};

Expand Down