From 751903029ca7b2f92c843d7dc03148f10902ddf9 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 6 May 2025 18:25:53 +0200 Subject: [PATCH] Refactor initial sync to happen outside of navigation context (#771) --- .../autofill-credential-created.tsx | 8 +- apps/mobile-app/app/_layout.tsx | 173 +++++++++++++++--- apps/mobile-app/app/index.tsx | 6 +- .../app/{sync.tsx => reinitialize.tsx} | 43 +++-- .../components/LoadingIndicator.tsx | 1 - apps/mobile-app/context/AuthContext.tsx | 10 +- 6 files changed, 183 insertions(+), 58 deletions(-) rename apps/mobile-app/app/{sync.tsx => reinitialize.tsx} (73%) diff --git a/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx b/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx index 68603de89..1da5c20cd 100644 --- a/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx @@ -23,17 +23,11 @@ export default function AutofillCredentialCreatedScreen() : React.ReactNode { router.back(); }, [router]); - // Handle app state changes to auto-dismiss when app comes back from background + // Handle app state changes to auto-dismiss when app goes to background useEffect(() => { - let wasInBackground = false; - const subscription = AppState.addEventListener('change', (nextAppState) => { if (nextAppState === 'background') { - wasInBackground = true; - } else if (nextAppState === 'active' && wasInBackground) { - // App is returning from background, dismiss the screen router.back(); - wasInBackground = false; } }); diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx index 1ae006a5e..3cb2839a7 100644 --- a/apps/mobile-app/app/_layout.tsx +++ b/apps/mobile-app/app/_layout.tsx @@ -1,25 +1,25 @@ +import { Linking, StyleSheet } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { useFonts } from 'expo-font'; -import { Stack } from 'expo-router'; +import { Href, Stack, useRouter } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; -import { useEffect } from 'react'; - 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 { install } from 'react-native-quick-crypto'; import { useColors, useColorScheme } from '@/hooks/useColorScheme'; -import { DbProvider } from '@/context/DbContext'; -import { AuthProvider } from '@/context/AuthContext'; +import { DbProvider, useDb } from '@/context/DbContext'; +import { AuthProvider, useAuth } from '@/context/AuthContext'; import { WebApiProvider } from '@/context/WebApiContext'; import { AliasVaultToast } from '@/components/Toast'; +import NativeVaultManager from '@/specs/NativeVaultManager'; +import { useVaultSync } from '@/hooks/useVaultSync'; import SpaceMono from '@/assets/fonts/SpaceMono-Regular.ttf'; +import LoadingIndicator from '@/components/LoadingIndicator'; +import { ThemedView } from '@/components/themed/ThemedView'; -// Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); /** @@ -28,8 +28,131 @@ SplashScreen.preventAutoHideAsync(); function RootLayoutNav() : React.ReactNode { const colorScheme = useColorScheme(); const colors = useColors(); + const router = useRouter(); + const [status, setStatus] = useState(''); + const { initializeAuth } = useAuth(); + const { syncVault } = useVaultSync(); + const dbContext = useDb(); + + const [bootComplete, setBootComplete] = useState(false); + const [redirectTarget, setRedirectTarget] = useState(null); + const hasBooted = useRef(false); + + useEffect(() => { + if (hasBooted.current) { + return; + } + + // Install the react-native-quick-crypto library which is used by the EncryptionUtility + install(); + + hasBooted.current = true; + + /** + * Initialize the app. + */ + const initialize = async () : Promise => { + const { isLoggedIn, enabledAuthMethods } = await initializeAuth(); + + if (!isLoggedIn) { + setRedirectTarget('/login'); + setBootComplete(true); + return; + } + + // Perform initial vault sync + await syncVault({ + initialSync: true, + /** + * Handle the status update. + */ + onStatus: (message) => { + setStatus(message); + } + }); + + const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); + if (hasEncryptedDatabase) { + const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); + if (!isFaceIDEnabled) { + setRedirectTarget('/unlock'); + setBootComplete(true); + return; + } + + setStatus('Unlocking vault'); + const isUnlocked = await dbContext.unlockVault(); + if (isUnlocked) { + await new Promise(resolve => setTimeout(resolve, 1000)); + setStatus('Decrypting vault'); + await new Promise(resolve => setTimeout(resolve, 1000)); + // The vault is successfully unlocked, so we let the native code handle the default routing. + } else { + setRedirectTarget('/unlock'); + } + } else { + setRedirectTarget('/unlock'); + } + + setBootComplete(true); + }; + + initialize(); + }, [dbContext, syncVault, initializeAuth]); + + useEffect(() => { + /** + * Simulate stack navigation. + */ + function simulateStackNavigation(from: string, to: string) : void { + router.replace(from as Href); + setTimeout(() => { + router.push(to as Href); + }, 0); + } + + /** + * Redirect to the target if we have one, otherwise check for a deep link and simulate stack navigation. + */ + const redirect = async () : Promise => { + if (bootComplete && redirectTarget) { + // If we have an explicit redirect target, we navigate to it. This overrides potential deep link handling. + router.replace(redirectTarget as Href); + } else if (bootComplete) { + const initialUrl = await Linking.getInitialURL(); + if (initialUrl) { + /** + * Check for certain deep link routes, and if found, ensure we simulate the stack navigation + * as otherwise the "back" button for navigation will not work as expected. + */ + const path = initialUrl.replace('net.aliasvault.app://', ''); + const isDetailRoute = path.includes('credentials/'); + if (isDetailRoute) { + simulateStackNavigation('/(tabs)/credentials', path); + } + } + } + }; + + redirect(); + }, [bootComplete, redirectTarget, router]); + + const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + }); + + if (!bootComplete) { + return ( + + {status ? : null} + + ); + } - // Create custom themes that extend the default ones. const customDefaultTheme = { ...DefaultTheme, colors: { @@ -50,22 +173,24 @@ function RootLayoutNav() : React.ReactNode { return ( - + - + diff --git a/apps/mobile-app/app/index.tsx b/apps/mobile-app/app/index.tsx index bb46d1202..0238fa4ec 100644 --- a/apps/mobile-app/app/index.tsx +++ b/apps/mobile-app/app/index.tsx @@ -1,13 +1,9 @@ import { Redirect } from 'expo-router'; -import { install } from 'react-native-quick-crypto'; /** * App index which is the entry point of the app and redirects to the sync screen, which will * redirect to the login screen if the user is not logged in or to the main tabs screen if the user is logged in. */ export default function AppIndex() : React.ReactNode { - // Install the react-native-quick-crypto library which is used by the EncryptionUtility - install(); - - return + return } \ No newline at end of file diff --git a/apps/mobile-app/app/sync.tsx b/apps/mobile-app/app/reinitialize.tsx similarity index 73% rename from apps/mobile-app/app/sync.tsx rename to apps/mobile-app/app/reinitialize.tsx index 592df29c5..0549869b7 100644 --- a/apps/mobile-app/app/sync.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -1,25 +1,27 @@ import { useEffect, useRef, useState } from 'react'; import { router } from 'expo-router'; -import { StyleSheet } from 'react-native'; - -import NativeVaultManager from '../specs/NativeVaultManager'; +import { StyleSheet, View } from 'react-native'; +import NativeVaultManager from '@/specs/NativeVaultManager'; import { useAuth } from '@/context/AuthContext'; import { useVaultSync } from '@/hooks/useVaultSync'; import { ThemedView } from '@/components/themed/ThemedView'; import LoadingIndicator from '@/components/LoadingIndicator'; import { useDb } from '@/context/DbContext'; +import { ThemedText } from '@/components/themed/ThemedText'; +import { useColors } from '@/hooks/useColorScheme'; /** - * Sync screen which will redirect to the login screen if the user is not logged in - * or to the credentials screen if the user is logged in. + * Reinitialize screen which is triggered when the app was still open but the database in memory + * was cleared because of a time-out. When this happens, we need to re-initialize and unlock the vault. */ -export default function SyncScreen() : React.ReactNode { +export default function ReinitializeScreen() : React.ReactNode { const authContext = useAuth(); const dbContext = useDb(); const { syncVault } = useVaultSync(); const [status, setStatus] = useState(''); const hasInitialized = useRef(false); + const colors = useColors(); useEffect(() => { if (hasInitialized.current) { @@ -103,17 +105,28 @@ export default function SyncScreen() : React.ReactNode { initialize(); }, [syncVault, authContext, dbContext]); + const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + message: { + textAlign: 'center', + }, + messageContainer: { + backgroundColor: colors.accentBackground, + borderRadius: 10, + padding: 20, + }, + }); + return ( - {status ? : null} + + Vault locked due to time-out, unlocking vault again... + {status ? : null} + ); } - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, -}); \ No newline at end of file diff --git a/apps/mobile-app/components/LoadingIndicator.tsx b/apps/mobile-app/components/LoadingIndicator.tsx index d27bd52aa..e4f1ee61e 100644 --- a/apps/mobile-app/components/LoadingIndicator.tsx +++ b/apps/mobile-app/components/LoadingIndicator.tsx @@ -98,7 +98,6 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea const styles = StyleSheet.create({ container: { alignItems: 'center', - flex: 1, justifyContent: 'center', padding: 20, }, diff --git a/apps/mobile-app/context/AuthContext.tsx b/apps/mobile-app/context/AuthContext.tsx index 811accf3a..5358b92be 100644 --- a/apps/mobile-app/context/AuthContext.tsx +++ b/apps/mobile-app/context/AuthContext.tsx @@ -257,14 +257,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // Check if vault is unlocked. const isUnlocked = await isVaultUnlocked(); if (!isUnlocked) { - // Database connection failed, navigate to unlock flow - router.replace('/sync'); - } else { - // Vault is still unlocked, staying on current screen + // Database connection failed, navigate to reinitialize flow + router.replace('/reinitialize'); } } catch { - // Database query failed, navigate to unlock flow - router.replace('/sync'); + // Database query failed, navigate to reinitialize flow + router.replace('/reinitialize'); } } }