diff --git a/mobile-app/app/(tabs)/(credentials)/index.tsx b/mobile-app/app/(tabs)/(credentials)/index.tsx index 11f16c7fa..75a7791d5 100644 --- a/mobile-app/app/(tabs)/(credentials)/index.tsx +++ b/mobile-app/app/(tabs)/(credentials)/index.tsx @@ -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) { diff --git a/mobile-app/app/_layout.tsx b/mobile-app/app/_layout.tsx index 9de4a4ab6..32bb11090 100644 --- a/mobile-app/app/_layout.tsx +++ b/mobile-app/app/_layout.tsx @@ -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('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 ( - - - - ); - } return ( - - {requireLoginOrUnlock ? ( - - ) : ( - - )} + + + + diff --git a/mobile-app/app/index.tsx b/mobile-app/app/index.tsx new file mode 100644 index 000000000..ce33ff3e3 --- /dev/null +++ b/mobile-app/app/index.tsx @@ -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 ( + + + + ); +} \ No newline at end of file diff --git a/mobile-app/hooks/useVaultSync.ts b/mobile-app/hooks/useVaultSync.ts new file mode 100644 index 000000000..7d35520e5 --- /dev/null +++ b/mobile-app/hooks/useVaultSync.ts @@ -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('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 }; +}; \ No newline at end of file