Add explicit offline mode override button during app sync flow (#1267)

This commit is contained in:
Leendert de Borst
2025-09-24 15:15:06 +02:00
committed by Leendert de Borst
parent 836e33f821
commit 7cb7c02bb2
4 changed files with 313 additions and 67 deletions

View File

@@ -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<NodeJS.Timeout | null>(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<void> => {
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<void> => {
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 (
<ThemedView style={styles.container}>
{status ? <LoadingIndicator status={status} /> : null}
{status ? (
<LoadingIndicator
status={status}
showOfflineButton={showOfflineButton}
onOfflinePress={handleOfflinePress}
/>
) : null}
</ThemedView>
);
}

View File

@@ -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<NodeJS.Timeout | null>(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<void> => {
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<string, string>
});
authContext.setReturnUrl(null);
return;
}
// Handle detail routes
const params = authContext.returnUrl.params as Record<string, string>;
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<void> => {
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 {
<View style={styles.messageContainer}>
<ThemedText style={styles.message1}>{t('app.reinitialize.vaultAutoLockedMessage')}</ThemedText>
<ThemedText style={styles.message2}>{t('app.reinitialize.attemptingToUnlockMessage')}</ThemedText>
{status ? <LoadingIndicator status={status} /> : null}
{status ? (
<LoadingIndicator
status={status}
showOfflineButton={showOfflineButton}
onOfflinePress={handleOfflinePress}
/>
) : null}
</View>
</ThemedView>
);

View File

@@ -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 (
<View style={styles.container}>
<View style={styles.dotsContainer}>
{showOfflineButton && (
<View style={styles.closeIconContainer}>
<TouchableOpacity style={styles.closeIcon} onPress={onOfflinePress}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</View>
)}
<View style={styles.contentContainer}>
<View style={styles.dotsContainer}>
<Animated.View
style={[
styles.dot,
@@ -181,10 +210,11 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea
]}
/>
</View>
<Text style={styles.statusText}>
{statusTrimmed}
{shouldShowDots && dots}
</Text>
<Text style={styles.statusText}>
{statusTrimmed}
{shouldShowDots && dots}
</Text>
</View>
</View>
);
}

View File

@@ -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)",