From 7cb7c02bb279c8af36873e477d9cf650b6687834 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 24 Sep 2025 15:15:06 +0200 Subject: [PATCH] Add explicit offline mode override button during app sync flow (#1267) --- apps/mobile-app/app/initialize.tsx | 153 ++++++++++++--- apps/mobile-app/app/reinitialize.tsx | 179 +++++++++++++++--- .../components/LoadingIndicator.tsx | 44 ++++- apps/mobile-app/i18n/locales/en.json | 4 +- 4 files changed, 313 insertions(+), 67 deletions(-) diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index 0f435a4f2..0a292ceeb 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -1,5 +1,5 @@ import { useRouter } from 'expo-router'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, StyleSheet } from 'react-native'; @@ -18,13 +18,90 @@ import NativeVaultManager from '@/specs/NativeVaultManager'; export default function Initialize() : React.ReactNode { const router = useRouter(); const [status, setStatus] = useState(''); + const [showOfflineButton, setShowOfflineButton] = useState(false); const hasInitialized = useRef(false); + const offlineButtonTimeoutRef = useRef(null); const { t } = useTranslation(); const { initializeAuth } = useAuth(); const { syncVault } = useVaultSync(); const dbContext = useDb(); const webApi = useWebApi(); + /** + * Handle offline scenario - show alert with options to open local vault or retry sync. + */ + const handleOfflineFlow = useCallback((): void => { + Alert.alert( + t('app.alerts.syncIssue'), + t('app.alerts.syncIssueMessage'), + [ + { + text: t('app.alerts.openLocalVault'), + /** + * Handle opening vault in read-only mode. + */ + onPress: async () : Promise => { + setStatus(t('app.status.openingVaultReadOnly')); + const { enabledAuthMethods } = await initializeAuth(); + + try { + const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); + + // No encrypted database + if (!hasEncryptedDatabase) { + router.replace('/unlock'); + return; + } + + // FaceID not enabled + const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); + if (!isFaceIDEnabled) { + router.replace('/unlock'); + return; + } + + // Attempt to unlock vault + setStatus(t('app.status.unlockingVault')); + const isUnlocked = await dbContext.unlockVault(); + + // Vault couldn't be unlocked + if (!isUnlocked) { + router.replace('/unlock'); + return; + } + + // Vault successfully unlocked - proceed with decryption + await new Promise(resolve => setTimeout(resolve, 750)); + setStatus(t('app.status.decryptingVault')); + await new Promise(resolve => setTimeout(resolve, 750)); + + // Migrations pending + if (await dbContext.hasPendingMigrations()) { + router.replace('/upgrade'); + return; + } + + // Success - navigate to credentials + router.replace('/(tabs)/credentials'); + } catch { + router.replace('/unlock'); + } + } + }, + { + text: t('app.alerts.retrySync'), + /** + * Handle retrying the connection. + */ + onPress: () : void => { + // Re-trigger initialization + hasInitialized.current = false; + } + } + ] + ); + }, [dbContext, router, initializeAuth, t]); + useEffect(() => { // Ensure this only runs once. if (hasInitialized.current) { @@ -100,6 +177,20 @@ export default function Initialize() : React.ReactNode { */ onStatus: (message) => { setStatus(message); + + // Clear any existing timeout + if (offlineButtonTimeoutRef.current) { + clearTimeout(offlineButtonTimeoutRef.current); + } + + // Show offline button after 2 seconds if we're checking vault updates + if (message === t('vault.checkingVaultUpdates')) { + offlineButtonTimeoutRef.current = setTimeout(() => { + setShowOfflineButton(true); + }, 2000) as NodeJS.Timeout; + } else { + setShowOfflineButton(false); + } }, /** * Handle successful vault sync and continue with vault unlock flow. @@ -111,33 +202,8 @@ export default function Initialize() : React.ReactNode { /** * Handle offline state and prompt user for action. */ - onOffline: async () => { - Alert.alert( - t('app.alerts.syncIssue'), - t('app.alerts.syncIssueMessage'), - [ - { - text: t('app.alerts.openLocalVault'), - /** - * Handle opening vault in read-only mode. - */ - onPress: async () : Promise => { - setStatus(t('app.status.openingVaultReadOnly')); - await handleVaultUnlock(); - } - }, - { - text: t('app.alerts.retrySync'), - /** - * Handle retrying the connection. - */ - onPress: () : void => { - setStatus(t('app.status.retryingConnection')); - initialize(); - } - } - ] - ); + onOffline: () => { + handleOfflineFlow(); }, /** * Handle error during vault sync. @@ -163,7 +229,28 @@ export default function Initialize() : React.ReactNode { }; initializeApp(); - }, [dbContext, syncVault, initializeAuth, webApi, router, t]); + + // Cleanup timeout on unmount + return (): void => { + if (offlineButtonTimeoutRef.current) { + clearTimeout(offlineButtonTimeoutRef.current); + } + }; + }, [dbContext, syncVault, initializeAuth, webApi, router, t, handleOfflineFlow]); + + /** + * Handle offline button press by calling the stored offline handler. + */ + const handleOfflinePress = (): void => { + // Clear any existing timeout + if (offlineButtonTimeoutRef.current) { + clearTimeout(offlineButtonTimeoutRef.current); + } + + setShowOfflineButton(false); + + handleOfflineFlow(); + }; const styles = StyleSheet.create({ container: { @@ -175,7 +262,13 @@ export default function Initialize() : React.ReactNode { return ( - {status ? : null} + {status ? ( + + ) : null} ); } \ No newline at end of file diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx index c9c38b9ee..a5af0236f 100644 --- a/apps/mobile-app/app/reinitialize.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -22,10 +22,117 @@ export default function ReinitializeScreen() : React.ReactNode { const dbContext = useDb(); const { syncVault } = useVaultSync(); const [status, setStatus] = useState(''); + const [showOfflineButton, setShowOfflineButton] = useState(false); const hasInitialized = useRef(false); + const offlineButtonTimeoutRef = useRef(null); const colors = useColors(); const { t } = useTranslation(); + /** + * Handle offline scenario - show alert with options to open local vault or retry sync. + */ + const handleOfflineFlow = (): void => { + Alert.alert( + t('app.alerts.syncIssue'), + t('app.alerts.syncIssueMessage'), + [ + { + text: t('app.alerts.openLocalVault'), + /** + * Handle opening vault in read-only mode. + */ + onPress: async () : Promise => { + setStatus(t('app.status.openingVaultReadOnly')); + const { enabledAuthMethods } = await authContext.initializeAuth(); + + try { + const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); + + // Guard clause: No encrypted database + if (!hasEncryptedDatabase) { + router.replace('/unlock'); + return; + } + + // Guard clause: FaceID not enabled + const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); + if (!isFaceIDEnabled) { + router.replace('/unlock'); + return; + } + + // Attempt to unlock vault + setStatus(t('app.status.unlockingVault')); + const isUnlocked = await dbContext.unlockVault(); + + // Guard clause: Vault couldn't be unlocked + if (!isUnlocked) { + router.replace('/unlock'); + return; + } + + // Vault successfully unlocked - proceed with decryption + await new Promise(resolve => setTimeout(resolve, 1000)); + setStatus(t('app.status.decryptingVault')); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Guard clause: Migrations pending + if (await dbContext.hasPendingMigrations()) { + router.replace('/upgrade'); + return; + } + + // Handle navigation based on return URL + if (!authContext.returnUrl?.path) { + router.replace('/(tabs)/credentials'); + return; + } + + // Navigate to return URL + const path = authContext.returnUrl.path as string; + const isDetailRoute = path.includes('credentials/'); + + if (!isDetailRoute) { + router.replace({ + pathname: path as '/', + params: authContext.returnUrl.params as Record + }); + authContext.setReturnUrl(null); + return; + } + + // Handle detail routes + const params = authContext.returnUrl.params as Record; + router.replace('/(tabs)/credentials'); + setTimeout(() => { + if (params.serviceUrl) { + router.push(path + '?serviceUrl=' + params.serviceUrl); + } else if (params.id) { + router.push(path + '?id=' + params.id); + } else { + router.push(path); + } + }, 0); + authContext.setReturnUrl(null); + } catch { + router.replace('/unlock'); + } + } + }, + { + text: t('app.alerts.retrySync'), + /** + * Handle retrying the connection. + */ + onPress: () : void => { + // Re-trigger initialization + hasInitialized.current = false; + } + } + ] + ); + }; + useEffect(() => { if (hasInitialized.current) { return; @@ -141,6 +248,20 @@ export default function ReinitializeScreen() : React.ReactNode { */ onStatus: (message) => { setStatus(message); + + // Clear any existing timeout + if (offlineButtonTimeoutRef.current) { + clearTimeout(offlineButtonTimeoutRef.current); + } + + // Show offline button after 2 seconds if we're checking vault updates + if (message === t('vault.checkingVaultUpdates')) { + offlineButtonTimeoutRef.current = setTimeout(() => { + setShowOfflineButton(true); + }, 2000) as NodeJS.Timeout; + } else { + setShowOfflineButton(false); + } }, /** * Handle successful vault sync and continue with vault unlock flow. @@ -152,32 +273,7 @@ export default function ReinitializeScreen() : React.ReactNode { * Handle offline state and prompt user for action. */ onOffline: () => { - Alert.alert( - t('app.alerts.syncIssue'), - t('app.alerts.syncIssueMessage'), - [ - { - text: t('app.alerts.openLocalVault'), - /** - * Handle opening vault in read-only mode. - */ - onPress: async () : Promise => { - setStatus(t('app.status.openingVaultReadOnly')); - await handleVaultUnlock(); - } - }, - { - text: t('app.alerts.retrySync'), - /** - * Handle retrying the connection. - */ - onPress: () : void => { - setStatus(t('app.status.retryingConnection')); - initialize(); - } - } - ] - ); + handleOfflineFlow(); }, /** * On upgrade required. @@ -189,7 +285,28 @@ export default function ReinitializeScreen() : React.ReactNode { }; initialize(); - }, [syncVault, authContext, dbContext, t]); + + // Cleanup timeout on unmount + return (): void => { + if (offlineButtonTimeoutRef.current) { + clearTimeout(offlineButtonTimeoutRef.current); + } + }; + }, [syncVault, authContext, dbContext, t, handleOfflineFlow]); + + /** + * Handle offline button press by calling the stored offline handler. + */ + const handleOfflinePress = (): void => { + // Clear any existing timeout + if (offlineButtonTimeoutRef.current) { + clearTimeout(offlineButtonTimeoutRef.current); + } + + setShowOfflineButton(false); + + handleOfflineFlow(); + }; const styles = StyleSheet.create({ container: { @@ -216,7 +333,13 @@ export default function ReinitializeScreen() : React.ReactNode { {t('app.reinitialize.vaultAutoLockedMessage')} {t('app.reinitialize.attemptingToUnlockMessage')} - {status ? : null} + {status ? ( + + ) : null} ); diff --git a/apps/mobile-app/components/LoadingIndicator.tsx b/apps/mobile-app/components/LoadingIndicator.tsx index d62f23922..e40b19579 100644 --- a/apps/mobile-app/components/LoadingIndicator.tsx +++ b/apps/mobile-app/components/LoadingIndicator.tsx @@ -1,16 +1,19 @@ import { useEffect, useRef, useState } from 'react'; -import { StyleSheet, View, Text, Animated, useColorScheme } from 'react-native'; +import { StyleSheet, View, Text, Animated, useColorScheme, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; import { useColors } from '@/hooks/useColorScheme'; type LoadingIndicatorProps = { status: string; + showOfflineButton?: boolean; + onOfflinePress?: () => void; }; /** * Loading indicator component. */ -export default function LoadingIndicator({ status }: LoadingIndicatorProps): React.ReactNode { +export default function LoadingIndicator({ status, showOfflineButton, onOfflinePress }: LoadingIndicatorProps): React.ReactNode { const colors = useColors(); const dot1Anim = useRef(new Animated.Value(0)).current; const dot2Anim = useRef(new Animated.Value(0)).current; @@ -100,6 +103,24 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea alignItems: 'center', justifyContent: 'center', padding: 20, + ...StyleSheet.absoluteFillObject, + }, + closeIconContainer: { + position: 'absolute', + right: 20, + top: 60, + zIndex: 10, + }, + closeIcon: { + padding: 12, + borderRadius: 32, + backgroundColor: colors.accentBackground, + borderWidth: 2, + borderColor: colors.accentBorder, + }, + contentContainer: { + alignItems: 'center', + justifyContent: 'center', }, dot: { backgroundColor: colors.tertiary, @@ -135,7 +156,15 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea return ( - + {showOfflineButton && ( + + + + + + )} + + - - {statusTrimmed} - {shouldShowDots && dots} - + + {statusTrimmed} + {shouldShowDots && dots} + + ); } diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index 291aa7cd4..a27a61fc3 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -433,11 +433,11 @@ "VAULT_ERROR": "The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again." }, "app": { + "openReadOnlyMode": "Open in read-only mode", "status": { "unlockingVault": "Unlocking vault", "decryptingVault": "Decrypting vault", - "openingVaultReadOnly": "Opening vault in read-only mode", - "retryingConnection": "Retrying connection..." + "openingVaultReadOnly": "Opening vault in read-only mode" }, "offline": { "banner": "Offline mode (read-only)",