Add separate index page that calls init logic (#771)

This commit is contained in:
Leendert de Borst
2025-04-17 18:40:45 +02:00
parent 39f8157683
commit c1ba8217fb
4 changed files with 161 additions and 138 deletions

View File

@@ -9,12 +9,14 @@ import { useDb } from '@/context/DbContext';
import { useAuth } from '@/context/AuthContext';
import { Credential } from '@/utils/types/Credential';
import { CredentialIcon } from '@/components/CredentialIcon';
import { useVaultSync } from '@/hooks/useVaultSync';
export default function CredentialsScreen() {
const colorScheme = useColorScheme();
const isDarkMode = colorScheme === 'dark';
const [searchQuery, setSearchQuery] = useState('');
const [refreshing, setRefreshing] = useState(false);
const { syncVault } = useVaultSync();
const dynamicStyles = {
credentialItem: {
@@ -68,8 +70,16 @@ export default function CredentialsScreen() {
// Record start time
const startTime = Date.now();
// Load data
const credentials = await dbContext.sqliteClient!.getAllCredentials();
// Sync vault and load credentials
await syncVault({
forceCheck: true,
onSuccess: async () => {
await loadCredentials();
},
onError: (error) => {
console.error('Error syncing vault:', error);
}
});
// Calculate remaining time needed to reach 350ms minimum
const elapsedTime = Date.now() - startTime;
@@ -79,15 +89,12 @@ export default function CredentialsScreen() {
if (remainingDelay > 0) {
await new Promise(resolve => setTimeout(resolve, remainingDelay));
}
// Update the data
setCredentialsList(credentials);
setRefreshing(false);
} catch (err) {
console.error('Error refreshing credentials:', err);
} finally {
setRefreshing(false);
}
}, []);
}, [syncVault, loadCredentials]);
useEffect(() => {
if (!isAuthenticated || !isDatabaseAvailable) {

View File

@@ -3,14 +3,13 @@ import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { useEffect, useCallback, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { useEffect } from 'react';
import { AppState, AppStateStatus, View, ActivityIndicator, Text } from 'react-native';
import 'react-native-reanimated';
// Required for certain modules such as secure-remote-password which relies on crypto.getRandomValues
// and this is not available in react-native without this polyfill
import 'react-native-get-random-values';
import Toast from 'react-native-toast-message';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useColorScheme } from '@/hooks/useColorScheme';
import { LoadingProvider } from '@/context/LoadingContext';
@@ -19,9 +18,7 @@ import { AuthProvider } from '@/context/AuthContext';
import { WebApiProvider } from '@/context/WebApiContext';
import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
import { useWebApi } from '@/context/WebApiContext';
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
import { View, ActivityIndicator, Text } from 'react-native';
import { useVaultSync } from '@/hooks/useVaultSync';
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -55,134 +52,13 @@ const toastConfig = {
function RootLayoutNav() {
const colorScheme = useColorScheme();
const authContext = useAuth();
const dbContext = useDb();
const webApi = useWebApi();
const isFirstMount = useRef(true);
// Check if user is authenticated and database is available
const isFullyInitialized = authContext.isInitialized && dbContext.dbInitialized;
const isAuthenticated = authContext.isLoggedIn;
const isDatabaseAvailable = dbContext.dbAvailable;
const requireLoginOrUnlock = isFullyInitialized && (!isAuthenticated || !isDatabaseAvailable);
/**
* Check for vault updates and fetch if necessary
*/
const checkVaultUpdates = useCallback(async (isStartup: boolean = false) => {
const isLoggedIn = await authContext.initializeAuth();
if (!isLoggedIn) {
console.log('Start check vault update: Not authenticated');
return;
}
// TODO: add check to know if the encryption key is available in the keychain
// If not, we should redirect to the unlock screen.
// Or do this check in the "isLoggedIn" check above?
const passwordHashBase64 = await AsyncStorage.getItem('passwordHash');
if (!passwordHashBase64) {
//console.log('Vault check error: Password hash not found');
//await webApi.logout('Password hash not found');
//rreturn;
}
console.log('Start check vault updates');
try {
// On startup, always check. Otherwise, check the time elapsed
if (!isStartup) {
const lastCheckStr = await AsyncStorage.getItem('lastVaultCheck');
const lastCheck = lastCheckStr ? parseInt(lastCheckStr, 10) : 0;
const now = Date.now();
// Only check if more than 1 hour has passed since last check
if (now - lastCheck < 3600000) {
console.log('Vault check skipped: Not enough time has passed since last check');
return;
}
}
console.log('Checking vault updates');
// Update last check time
await AsyncStorage.setItem('lastVaultCheck', Date.now().toString());
// Do status check to ensure this mobile app is supported and check vault revision
const statusResponse = await webApi.getStatus();
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError !== null) {
console.log('Vault check error:', statusError);
await webApi.logout(statusError);
return;
}
// If the vault revision is higher, fetch the latest vault
const vaultMetadata = await dbContext.getVaultMetadata();
const vaultRevisionNumber = vaultMetadata?.vaultRevisionNumber ?? 0;
console.log('Vault revision:', statusResponse.vaultRevision);
console.log('Current vault revision:', vaultRevisionNumber);
if (statusResponse.vaultRevision > vaultRevisionNumber) {
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
const vaultError = webApi.validateVaultResponse(vaultResponseJson);
if (vaultError) {
console.log('Vault check error:', vaultError);
console.log('Vault response:', vaultResponseJson);
await webApi.logout(vaultError);
return;
}
// Initialize the SQLite context again with the newly retrieved decrypted blob
// TODO: set encryption key in initializedatabase as separate method to make
// it more clean.
console.log('Re-initializing database with new vault');
await dbContext.initializeDatabase(vaultResponseJson, null);
}
else {
console.log('Vault check finished: Vault revision is the same, no action requiredr');
}
} catch (err) {
console.error('Vault check error:', err);
}
}, [isAuthenticated, dbContext, webApi]);
// Handle app state changes and initial mount
useEffect(() => {
// On first mount (cold boot), always check
if (isFirstMount.current) {
isFirstMount.current = false;
checkVaultUpdates(true);
}
const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
checkVaultUpdates(false);
}
});
return () => {
subscription.remove();
};
}, [checkVaultUpdates]);
// Show loading screen while initializing
if (!isFullyInitialized) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
{requireLoginOrUnlock ? (
<Stack.Screen name="login" />
) : (
<Stack.Screen name="(tabs)" />
)}
<Stack screenOptions={{ headerShown: false, animation: 'none' }}>
<Stack.Screen name="index" />
<Stack.Screen name="login" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />

48
mobile-app/app/index.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
import { useVaultSync } from '@/hooks/useVaultSync';
import { ThemedView } from '@/components/ThemedView';
export default function InitialLoadingScreen() {
const { isInitialized: isAuthInitialized, isLoggedIn } = useAuth();
const { dbInitialized, dbAvailable } = useDb();
const { syncVault } = useVaultSync();
const hasInitialized = useRef(false);
const isFullyInitialized = isAuthInitialized && dbInitialized;
const requireLoginOrUnlock = isFullyInitialized && (!isLoggedIn || !dbAvailable);
useEffect(() => {
async function initialize() {
if (hasInitialized.current) {
return;
}
hasInitialized.current = true;
// Perform initial vault sync
console.log('Initial vault sync');
await syncVault({ forceCheck: true });
// Navigate to appropriate screen
if (requireLoginOrUnlock) {
console.log('Navigating to login');
router.replace('/login');
} else {
console.log('Navigating to credentials');
router.replace('/(tabs)/(credentials)');
}
}
initialize();
}, [isFullyInitialized, requireLoginOrUnlock, syncVault]); // Keep all dependencies to satisfy ESLint
return (
<ThemedView style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#f97316" />
</ThemedView>
);
}

View File

@@ -0,0 +1,92 @@
import { useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
import { useWebApi } from '@/context/WebApiContext';
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
interface VaultSyncOptions {
forceCheck?: boolean;
onSuccess?: () => void;
onError?: (error: string) => void;
}
export const useVaultSync = () => {
const authContext = useAuth();
const dbContext = useDb();
const webApi = useWebApi();
const syncVault = useCallback(async (options: VaultSyncOptions = {}) => {
const { forceCheck = false, onSuccess, onError } = options;
console.log('syncVault called with forceCheck:', forceCheck);
try {
const isLoggedIn = await authContext.initializeAuth();
if (!isLoggedIn) {
console.log('Vault sync: Not authenticated');
return false;
}
// If not forcing a check, verify the time elapsed since last check
if (!forceCheck) {
const lastCheckStr = await AsyncStorage.getItem('lastVaultCheck');
const lastCheck = lastCheckStr ? parseInt(lastCheckStr, 10) : 0;
const now = Date.now();
// Only check if more than 1 hour has passed since last check
if (now - lastCheck < 3600000) {
console.log('Vault sync skipped: Not enough time has passed since last check');
return false;
}
}
console.log('Checking vault updates');
// Update last check time
await AsyncStorage.setItem('lastVaultCheck', Date.now().toString());
// Check app status and vault revision
const statusResponse = await webApi.getStatus();
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError !== null) {
console.log('Vault sync error:', statusError);
await webApi.logout(statusError);
onError?.(statusError);
return false;
}
// Compare vault revisions
const vaultMetadata = await dbContext.getVaultMetadata();
const vaultRevisionNumber = vaultMetadata?.vaultRevisionNumber ?? 0;
if (statusResponse.vaultRevision > vaultRevisionNumber) {
const vaultResponseJson = await webApi.get<VaultResponse>('Vault');
const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse);
if (vaultError) {
console.log('Vault sync error:', vaultError);
await webApi.logout(vaultError);
onError?.(vaultError);
return false;
}
console.log('Re-initializing database with new vault');
await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, null);
onSuccess?.();
return true;
}
console.log('Vault sync finished: No updates needed');
onSuccess?.();
return false;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync';
console.error('Vault sync error:', err);
onError?.(errorMessage);
return false;
}
}, [authContext, dbContext, webApi]);
return { syncVault };
};