Refactor deep linking to work better with vault lock flow (#1347)

This commit is contained in:
Leendert de Borst
2025-11-18 16:51:14 +01:00
parent 267f2d3d17
commit 359f911057
14 changed files with 472 additions and 241 deletions

View File

@@ -5,7 +5,7 @@
"version": "0.25.0-alpha",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "net.aliasvault.app",
"scheme": ["aliasvault", "net.aliasvault.app"],
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"platforms": [

View File

@@ -134,14 +134,14 @@ export default function SettingsLayout(): React.ReactNode {
}}
/>
<Stack.Screen
name="qr-confirm"
name="mobile-unlock/[id]"
options={{
title: t('settings.qrScanner.mobileLogin.confirmTitle'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="qr-result"
name="mobile-unlock/result"
options={{
title: t('common.success'),
...defaultHeaderOptions,

View File

@@ -18,23 +18,24 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
/**
* QR Code confirmation screen for mobile login.
*/
export default function QRConfirmScreen() : React.ReactNode {
export default function MobileUnlockConfirmScreen() : React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const webApi = useWebApi();
const insets = useSafeAreaInsets();
const { requestId } = useLocalSearchParams<{ requestId: string }>();
const { id } = useLocalSearchParams();
const [isProcessing, setIsProcessing] = useState(false);
/**
* Handle mobile login QR code.
*/
const handleMobileLogin = async (requestId: string) : Promise<void> => {
const handleMobileLogin = async (id: string) : Promise<void> => {
try {
// Fetch the public key from server
console.log('makig request to /auth/mobile-login/request', id);
const response = await webApi.authFetch<{ clientPublicKey: string }>(
`auth/mobile-login/request/${requestId}`,
`auth/mobile-login/request/${id}`,
{ method: 'GET' }
);
@@ -52,7 +53,7 @@ export default function QRConfirmScreen() : React.ReactNode {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requestId,
requestId: id,
encryptedDecryptionKey: encryptedKey,
}),
}
@@ -60,7 +61,7 @@ export default function QRConfirmScreen() : React.ReactNode {
// Success! Navigate to result page
router.replace({
pathname: '/(tabs)/settings/qr-result',
pathname: '/(tabs)/settings/mobile-unlock/result',
params: {
success: 'true',
message: t('settings.qrScanner.mobileLogin.successDescription'),
@@ -72,7 +73,7 @@ export default function QRConfirmScreen() : React.ReactNode {
// Error! Navigate to result page
router.replace({
pathname: '/(tabs)/settings/qr-result',
pathname: '/(tabs)/settings/mobile-unlock/result',
params: {
success: 'false',
message: errorMsg,
@@ -85,7 +86,7 @@ export default function QRConfirmScreen() : React.ReactNode {
* Handle confirmation - authenticate user first, then process the scanned QR code.
*/
const handleConfirm = async () : Promise<void> => {
if (!requestId) {
if (!id) {
return;
}
@@ -103,10 +104,10 @@ export default function QRConfirmScreen() : React.ReactNode {
{
text: t('common.ok'),
/**
* Go back to the settings tab.
* Navigate to the settings tab.
*/
onPress: (): void => {
router.back();
router.replace('/(tabs)/settings');
},
},
]
@@ -128,10 +129,10 @@ export default function QRConfirmScreen() : React.ReactNode {
{
text: t('common.ok'),
/**
* Go back to the previous screen.
* Navigate to the settings tab.
*/
onPress: (): void => {
router.back();
router.replace('/(tabs)/settings');
},
},
]
@@ -152,7 +153,7 @@ export default function QRConfirmScreen() : React.ReactNode {
}
// Process the mobile login
await handleMobileLogin(requestId);
await handleMobileLogin(id as string);
} catch (error) {
console.error('Authentication or QR code processing error:', error);
Alert.alert(
@@ -165,10 +166,11 @@ export default function QRConfirmScreen() : React.ReactNode {
};
/**
* Handle dismiss - go back to settings.
* Handle dismiss - navigate to settings tab.
* Uses replace to handle cases where this page is the first in the navigation stack (deep link).
*/
const handleDismiss = () : void => {
router.back();
router.replace('/(tabs)/settings');
};
const styles = StyleSheet.create({
@@ -208,6 +210,8 @@ export default function QRConfirmScreen() : React.ReactNode {
},
});
console.log('[_qrconfirm] rendered with id:', id);
// Show loading during processing
if (isProcessing) {
return (

View File

@@ -14,7 +14,7 @@ import { ThemedText } from '@/components/themed/ThemedText';
/**
* QR Code result screen - shows success or error after mobile unlock attempt.
*/
export default function QRResultScreen() : React.ReactNode {
export default function MobileUnlockResultScreen() : React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -23,13 +23,11 @@ export default function QRResultScreen() : React.ReactNode {
const isSuccess = success === 'true';
/**
* Handle dismiss which will go back to the settings tab.
* Handle dismiss - navigate to settings tab.
* Uses replace to handle cases where this page is reached via deep link navigation.
*/
const handleDismiss = () : void => {
/*
* Go back to the settings tab.
*/
router.back();
router.replace('/(tabs)/settings');
};
const styles = StyleSheet.create({

View File

@@ -1,6 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { router, useLocalSearchParams } from 'expo-router';
import { Href, router, useLocalSearchParams } from 'expo-router';
import { useEffect, useCallback, useRef } from 'react';
import { View, Alert, StyleSheet } from 'react-native';
@@ -15,10 +15,11 @@ import { useWebApi } from '@/context/WebApiContext';
// QR Code type prefixes
const QR_CODE_PREFIXES = {
MOBILE_LOGIN: 'aliasvault://mobile-login/',
MOBILE_UNLOCK: 'aliasvault://open/mobile-unlock/',
/*
* Future: PASSKEY: 'aliasvault://passkey/',
* Future: SHARE_CREDENTIAL: 'aliasvault://share/',
* Future actions:
* PASSKEY_AUTH: 'aliasvault://open/passkey-auth/',
* SHARE_CREDENTIAL: 'aliasvault://open/share-credential/',
*/
} as const;
@@ -98,7 +99,7 @@ export default function QRScannerScreen() : React.ReactNode {
setIsLoadingAfterScan(true);
try {
if (parsedData.type === 'MOBILE_LOGIN') {
if (parsedData.type === 'MOBILE_UNLOCK' || parsedData.type === 'MOBILE_LOGIN') {
// Fetch the public key from server to validate the request exists
await webApi.authFetch<{ clientPublicKey: string }>(
`auth/mobile-login/request/${parsedData.payload}`,
@@ -111,10 +112,8 @@ export default function QRScannerScreen() : React.ReactNode {
*/
setIsLoadingAfterScan(false);
router.replace({
pathname: '/(tabs)/settings/qr-confirm',
params: { requestId: parsedData.payload },
});
console.log('[_qrscanner] navigate to mobile-unlock with replace:', parsedData.payload);
router.replace(`/(tabs)/settings/mobile-unlock/${parsedData.payload}` as Href);
}
} catch (error) {
setIsLoadingAfterScan(false);
@@ -124,6 +123,7 @@ export default function QRScannerScreen() : React.ReactNode {
if (error instanceof Error) {
if (error.message.includes('404')) {
// Request expired
errorMsg = t('settings.qrScanner.mobileLogin.requestExpired');
} else {
errorMsg = t('common.errors.unknownErrorTryAgain');
@@ -135,6 +135,9 @@ export default function QRScannerScreen() : React.ReactNode {
errorMsg,
[{ text: t('common.ok') }]
);
// On error, go back to the settings screen
router.replace('/(tabs)/settings');
}
}, [webApi, setIsLoadingAfterScan, t]);
@@ -150,7 +153,6 @@ export default function QRScannerScreen() : React.ReactNode {
// Prevent processing the same URL multiple times
if (processedUrls.current.has(data)) {
console.debug('QR code already processed, ignoring duplicate:', data);
return;
}

View File

@@ -1,4 +1,5 @@
import { Link, Stack } from 'expo-router';
import { Link, Stack, usePathname, useGlobalSearchParams } from 'expo-router';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet } from 'react-native';
@@ -10,7 +11,17 @@ import { ThemedView } from '@/components/themed/ThemedView';
*/
export default function NotFoundScreen() : React.ReactNode {
const { t } = useTranslation();
const pathname = usePathname();
const params = useGlobalSearchParams();
useEffect(() => {
console.error('[NotFound] 404 - Page not found:', {
pathname,
params,
timestamp: new Date().toISOString(),
});
}, [pathname, params]);
return (
<>
<Stack.Screen options={{ title: t('app.notFound.title') }} />

View File

@@ -12,6 +12,7 @@ import { install } from 'react-native-quick-crypto';
import { useColors, useColorScheme } from '@/hooks/useColorScheme';
import SpaceMono from '@/assets/fonts/SpaceMono-Regular.ttf';
import LoadingIndicator from '@/components/LoadingIndicator';
import { ThemedView } from '@/components/themed/ThemedView';
import { AliasVaultToast } from '@/components/Toast';
import { AppProvider } from '@/context/AppContext';
@@ -34,7 +35,6 @@ function RootLayoutNav() : React.ReactNode {
const [bootComplete, setBootComplete] = useState(false);
const [redirectTarget, setRedirectTarget] = useState<string | null>(null);
const hasBooted = useRef(false);
const processedDeepLinks = useRef(new Set<string>());
useEffect(() => {
/**
@@ -75,45 +75,6 @@ function RootLayoutNav() : React.ReactNode {
}, []);
useEffect(() => {
/**
* Handle deep link URL by navigating to the appropriate route.
*/
const handleDeepLink = (url: string) : void => {
// Prevent processing the same deep link multiple times
if (processedDeepLinks.current.has(url)) {
console.debug('Deep link already processed, ignoring duplicate:', url);
return;
}
// Mark this URL as processed
processedDeepLinks.current.add(url);
// Remove all supported URL schemes to get the path
let path = url
.replace('net.aliasvault.app://', '')
.replace('aliasvault://', '')
.replace('exp+aliasvault://', '');
// Handle mobile login QR code scans from native camera
if (path.startsWith('mobile-login/')) {
// Process the QR code (app already unlocked when listener fires)
router.push(`/(tabs)/settings/qr-scanner?url=${encodeURIComponent(`aliasvault://${path}`)}` as Href);
return;
}
// Handle credential detail routes
const isDetailRoute = path.includes('credentials/');
if (isDetailRoute) {
// First go to the credentials tab.
router.replace('/(tabs)/credentials');
// Then push the target route inside the credentials tab.
setTimeout(() => {
router.push(path as Href);
}, 0);
}
};
/**
* Redirect to a explicit target page if we have one (in case of non-happy path).
*/
@@ -129,15 +90,6 @@ function RootLayoutNav() : React.ReactNode {
};
redirect();
// Listen for deep links when app is already running and unlocked
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url);
});
return (): void => {
subscription.remove();
};
}, [bootComplete, redirectTarget, router]);
const styles = StyleSheet.create({
@@ -152,6 +104,7 @@ function RootLayoutNav() : React.ReactNode {
return (
<ThemedView style={styles.container}>
{/* Loading state while booting */}
<LoadingIndicator />
</ThemedView>
);
}

View File

@@ -4,6 +4,8 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
import { PostUnlockNavigation } from '@/utils/PostUnlockNavigation';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultSync } from '@/hooks/useVaultSync';
@@ -32,6 +34,16 @@ export default function Initialize() : React.ReactNode {
const dbContext = useDb();
const colors = useColors();
/**
* Build unlock URL with pending deep link parameter if available.
*/
const getUnlockUrl = useCallback((): Href => {
if (pendingDeepLink) {
return `/unlock?pendingDeepLink=${encodeURIComponent(pendingDeepLink)}` as Href;
}
return '/unlock';
}, [pendingDeepLink]);
/**
* Update status with smart skip button logic.
* Normalizes status by removing animation dots and manages skip button visibility.
@@ -66,35 +78,6 @@ export default function Initialize() : React.ReactNode {
}
}, []);
/**
* Handle pending deep link after successful unlock.
*/
const handlePendingDeepLink = useCallback((deepLink: string): void => {
// Remove all supported URL schemes to get the path
let path = deepLink
.replace('net.aliasvault.app://', '')
.replace('aliasvault://', '')
.replace('exp+aliasvault://', '');
// Handle mobile login QR code scans from native camera
if (path.startsWith('mobile-login/')) {
router.replace(`/(tabs)/settings/qr-scanner?url=${encodeURIComponent(`aliasvault://${path}`)}` as Href);
return;
}
// Handle credential detail routes
const isDetailRoute = path.includes('credentials/');
if (isDetailRoute) {
// First go to the credentials tab.
router.replace('/(tabs)/credentials');
// Then push the target route inside the credentials tab.
setTimeout(() => {
router.push(path as Href);
}, 0);
}
}, [router]);
/**
* Handle offline scenario - show alert with options to open local vault or retry sync.
*/
@@ -102,11 +85,15 @@ export default function Initialize() : React.ReactNode {
// Don't show the alert if we're already in offline mode
if (app.isOffline) {
console.debug('Already in offline mode, skipping offline flow alert');
if (pendingDeepLink) {
handlePendingDeepLink(pendingDeepLink);
} else {
router.replace('/(tabs)/credentials');
}
PostUnlockNavigation.navigate({
pendingDeepLink,
returnUrl: app.returnUrl,
router,
/**
* Clear the return URL after navigation.
*/
clearReturnUrl: () => app.setReturnUrl(null),
});
return;
}
@@ -128,7 +115,7 @@ export default function Initialize() : React.ReactNode {
// No encrypted database
if (!hasEncryptedDatabase) {
router.replace('/unlock');
router.replace(getUnlockUrl());
return;
}
@@ -138,7 +125,7 @@ export default function Initialize() : React.ReactNode {
// FaceID not enabled
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
if (!isFaceIDEnabled) {
router.replace('/unlock');
router.replace(getUnlockUrl());
return;
}
@@ -148,7 +135,7 @@ export default function Initialize() : React.ReactNode {
// Vault couldn't be unlocked
if (!isUnlocked) {
router.replace('/unlock');
router.replace(getUnlockUrl());
return;
}
@@ -161,15 +148,19 @@ export default function Initialize() : React.ReactNode {
return;
}
// Success - check for pending deep link or navigate to credentials
if (pendingDeepLink) {
handlePendingDeepLink(pendingDeepLink);
} else {
router.replace('/(tabs)/credentials');
}
// Success - use centralized navigation logic
PostUnlockNavigation.navigate({
pendingDeepLink,
returnUrl: app.returnUrl,
router,
/**
* Clear the return URL after navigation.
*/
clearReturnUrl: () => app.setReturnUrl(null),
});
} catch (err) {
console.error('Error during offline vault unlock:', err);
router.replace('/unlock');
router.replace(getUnlockUrl());
}
}
},
@@ -207,7 +198,7 @@ export default function Initialize() : React.ReactNode {
}
]
);
}, [dbContext, router, app, t, updateStatus, pendingDeepLink, handlePendingDeepLink]);
}, [dbContext, router, app, t, updateStatus, pendingDeepLink, getUnlockUrl]);
useEffect(() => {
// Ensure this only runs once.
@@ -254,7 +245,7 @@ export default function Initialize() : React.ReactNode {
if (!isUnlocked) {
// Failed to unlock, redirect to unlock screen
router.replace('/unlock');
router.replace(getUnlockUrl());
return;
}
@@ -268,13 +259,13 @@ export default function Initialize() : React.ReactNode {
canShowSkipButtonRef.current = true;
} else {
// No FaceID, redirect to unlock screen for manual unlock
router.replace('/unlock');
router.replace(getUnlockUrl());
return;
}
}
} catch (err) {
console.error('Error during initial vault unlock:', err);
router.replace('/unlock');
router.replace(getUnlockUrl());
return;
}
} else {
@@ -310,13 +301,16 @@ export default function Initialize() : React.ReactNode {
* Handle successful vault sync.
*/
onSuccess: async () => {
// Check if we have a pending deep link to process
if (pendingDeepLink) {
handlePendingDeepLink(pendingDeepLink);
} else {
// Vault already unlocked, just navigate to credentials
router.replace('/(tabs)/credentials');
}
// Use centralized navigation logic
PostUnlockNavigation.navigate({
pendingDeepLink,
returnUrl: app.returnUrl,
router,
/**
* Clear the return URL after navigation.
*/
clearReturnUrl: () => app.setReturnUrl(null),
});
},
/**
* Handle offline state and prompt user for action.
@@ -337,7 +331,7 @@ export default function Initialize() : React.ReactNode {
error,
[{ text: t('common.ok'), style: 'default' }]
);
router.replace('/unlock');
router.replace(getUnlockUrl());
return;
},
/**
@@ -360,7 +354,7 @@ export default function Initialize() : React.ReactNode {
clearTimeout(skipButtonTimeoutRef.current);
}
};
}, [dbContext, syncVault, app, router, t, handleOfflineFlow, updateStatus, pendingDeepLink, handlePendingDeepLink]);
}, [dbContext, syncVault, app, router, t, handleOfflineFlow, updateStatus, pendingDeepLink, getUnlockUrl]);
/**
* Handle skip button press by calling the offline handler.

View File

@@ -0,0 +1,69 @@
import { Href, useRouter, useLocalSearchParams, useGlobalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
/**
* Action-based deep link handler for special actions triggered from outside the app.
*
* URL structure: aliasvault://open/[action]/[...params]
*
* Supported actions:
* - mobile-unlock/[requestId] - Mobile device unlock via QR code
*
* This route exists to handle deep links that Expo Router processes before our
* Linking.addEventListener can intercept them. It provides proper navigation
* flow for each action type.
*/
export default function ActionHandler() : null {
const router = useRouter();
const params = useGlobalSearchParams();
const localParams = useLocalSearchParams();
const [hasNavigated, setHasNavigated] = useState(false);
useEffect(() => {
if (hasNavigated) {
return;
}
// Get the path segments (first segment is the action)
const pathSegments = (params.path || localParams.path) as string[] | string | undefined;
const pathArray = Array.isArray(pathSegments) ? pathSegments : pathSegments ? [pathSegments] : [];
if (pathArray.length === 0) {
// No action specified, go to credentials
router.replace('/(tabs)/credentials');
setHasNavigated(true);
return;
}
const [action, ...actionParams] = pathArray;
// Handle different action types
switch (action) {
case 'mobile-unlock': {
// Mobile unlock action: $/mobile-unlock/[requestId]
const requestId = actionParams[0];
if (!requestId) {
console.error('[ActionHandler] mobile-unlock requires requestId');
router.replace('/(tabs)/settings');
setHasNavigated(true);
return;
}
// First navigate to settings tab to establish correct navigation stack
console.log('[_actionhandler] navigate to qr-confirm');
router.replace(`/(tabs)/settings/mobile-unlock/${requestId}` as Href);
setHasNavigated(true);
break;
}
default:
// Unknown action, log and go to credentials
console.warn('[ActionHandler] Unknown action:', action);
router.replace('/(tabs)/credentials');
setHasNavigated(true);
break;
}
}, [params, localParams, router, hasNavigated]);
return null;
}

View File

@@ -1,9 +1,11 @@
import { Ionicons } from '@expo/vector-icons';
import { Href, router } from 'expo-router';
import { router } from 'expo-router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, Alert, TouchableOpacity } from 'react-native';
import { PostUnlockNavigation } from '@/utils/PostUnlockNavigation';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultSync } from '@/hooks/useVaultSync';
@@ -120,38 +122,15 @@ export default function ReinitializeScreen() : React.ReactNode {
return;
}
// Handle navigation based on return URL
if (!app.returnUrl?.path) {
router.replace('/(tabs)/credentials');
return;
}
// Navigate to return URL
const path = app.returnUrl.path as string;
const isDetailRoute = path.includes('credentials/');
if (!isDetailRoute) {
router.replace({
pathname: path as '/',
params: app.returnUrl.params as Record<string, string>
});
app.setReturnUrl(null);
return;
}
// Handle detail routes
const params = app.returnUrl.params as Record<string, string>;
router.replace('/(tabs)/credentials');
setTimeout(() => {
if (params.serviceUrl) {
router.push(`${path}?serviceUrl=${params.serviceUrl}` as Href);
} else if (params.id) {
router.push(`${path}?id=${params.id}` as Href);
} else {
router.push(path as Href);
}
}, 0);
app.setReturnUrl(null);
// Use centralized navigation logic
PostUnlockNavigation.navigate({
returnUrl: app.returnUrl,
router,
/**
* Clear the return URL after navigation.
*/
clearReturnUrl: () => app.setReturnUrl(null),
});
} catch (err) {
console.error('Error during offline vault unlock:', err);
router.replace('/unlock');
@@ -195,49 +174,6 @@ export default function ReinitializeScreen() : React.ReactNode {
hasInitialized.current = true;
/**
* Redirect to the return URL.
*/
function redirectToReturnUrl() : void {
/**
* Simulate stack navigation.
*/
function simulateStackNavigation(from: string, to: string) : void {
router.replace(from as Href);
setTimeout(() => {
router.push(to as Href);
}, 0);
}
if (app.returnUrl?.path) {
// Type assertion needed due to router type limitations
const path = app.returnUrl.path as '/';
const isDetailRoute = path.includes('credentials/');
if (isDetailRoute) {
// If there is a "serviceUrl" or "id" param from the return URL, use it.
const params = app.returnUrl.params as Record<string, string>;
if (params.serviceUrl) {
simulateStackNavigation('/(tabs)/credentials', `${path}?serviceUrl=${params.serviceUrl}`);
} else if (params.id) {
simulateStackNavigation('/(tabs)/credentials', `${path}?id=${params.id}`);
} else {
simulateStackNavigation('/(tabs)/credentials', path as string);
}
} else {
router.replace({
pathname: path,
params: app.returnUrl.params as Record<string, string>
});
}
// Clear the return URL after using it
app.setReturnUrl(null);
} else {
// If there is no return URL, navigate to the credentials tab as default entry page.
router.replace('/(tabs)/credentials');
}
}
/**
* Initialize the app.
*/
@@ -333,8 +269,14 @@ export default function ReinitializeScreen() : React.ReactNode {
* Handle successful vault sync.
*/
onSuccess: async () => {
// Vault already unlocked, just navigate to return URL
redirectToReturnUrl();
PostUnlockNavigation.navigate({
returnUrl: app.returnUrl,
router,
/**
* Clear the return URL after navigation.
*/
clearReturnUrl: () => app.setReturnUrl(null),
});
},
/**
* Handle error during vault sync.
@@ -342,8 +284,15 @@ export default function ReinitializeScreen() : React.ReactNode {
*/
onError: (error: string) => {
console.error('Vault sync error during reinitialize:', error);
// Even if sync fails, vault is already unlocked, so navigate to return URL
redirectToReturnUrl();
// Even if sync fails, vault is already unlocked, use centralized navigation
PostUnlockNavigation.navigate({
returnUrl: app.returnUrl,
router,
/**
* Clear the return URL after navigation.
*/
clearReturnUrl: () => app.setReturnUrl(null),
});
},
/**
* Handle offline state and prompt user for action.

View File

@@ -2,11 +2,12 @@ import { Buffer } from 'buffer';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import { router, useLocalSearchParams } from 'expo-router';
import { useState, useEffect, useCallback } from 'react';
import { StyleSheet, View, TextInput, Alert, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableWithoutFeedback, Keyboard, Text, Pressable } from 'react-native';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { PostUnlockNavigation } from '@/utils/PostUnlockNavigation';
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
import { useColors } from '@/hooks/useColorScheme';
@@ -26,8 +27,9 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
* Unlock screen.
*/
export default function UnlockScreen() : React.ReactNode {
const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams, logout } = useApp();
const { isLoggedIn, username, isBiometricsEnabled, getBiometricDisplayName, getEncryptionKeyDerivationParams, logout, returnUrl, setReturnUrl } = useApp();
const dbContext = useDb();
const { pendingDeepLink } = useLocalSearchParams<{ pendingDeepLink?: string }>();
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isBiometricsAvailable, setIsBiometricsAvailable] = useState(false);
@@ -79,10 +81,18 @@ export default function UnlockScreen() : React.ReactNode {
}
/*
* Navigate to initialize page which will handle vault sync and then navigate to credentials
* This ensures we always check for vault updates even after local unlock
* Navigate using centralized navigation logic
* This ensures we handle pending deep links and return URLs correctly
*/
router.replace('/initialize');
PostUnlockNavigation.navigate({
pendingDeepLink,
returnUrl,
router,
/**
* Clear the return URL after navigation.
*/
clearReturnUrl: () => setReturnUrl(null),
});
} else {
// If db is not available for whatever reason, fallback to password unlock.
setIsLoading(false);
@@ -106,7 +116,7 @@ export default function UnlockScreen() : React.ReactNode {
return;
}
}
}, [dbContext, t, setPinAvailable]);
}, [dbContext, t, setPinAvailable, pendingDeepLink, returnUrl, setReturnUrl]);
useEffect(() => {
getKeyDerivationParams();
@@ -188,10 +198,18 @@ export default function UnlockScreen() : React.ReactNode {
}
/*
* Navigate to initialize page which will handle vault sync and then navigate to credentials
* This ensures we always check for vault updates even after local unlock
* Navigate using centralized navigation logic
* This ensures we handle pending deep links and return URLs correctly
*/
router.replace('/initialize');
PostUnlockNavigation.navigate({
pendingDeepLink,
returnUrl,
router,
/**
* Clear the return URL after navigation.
*/
clearReturnUrl: () => setReturnUrl(null),
});
} else {
Alert.alert(
t('common.error'),

View File

@@ -35,8 +35,8 @@ type AppContextType = {
shouldShowAutofillReminder: boolean;
markAutofillConfigured: () => Promise<void>;
// Return URL methods
returnUrl: { path: string; params?: object } | null;
setReturnUrl: (url: { path: string; params?: object } | null) => void;
returnUrl: { path: string; params?: Record<string, string> | undefined } | null;
setReturnUrl: (url: { path: string; params?: Record<string, string> } | null) => void;
}
export type AuthMethod = 'faceid' | 'password';
@@ -126,7 +126,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
verifyPassword: auth.verifyPassword,
getEncryptionKeyDerivationParams: auth.getEncryptionKeyDerivationParams,
markAutofillConfigured: auth.markAutofillConfigured,
setReturnUrl: auth.setReturnUrl,
setReturnUrl: auth.setReturnUrl
}), [
auth.isInitialized,
auth.isLoggedIn,
@@ -152,7 +152,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
auth.getEncryptionKeyDerivationParams,
auth.markAutofillConfigured,
auth.setReturnUrl,
logout,
logout
]);
return (

View File

@@ -6,7 +6,6 @@ import * as LocalAuthentication from 'expo-local-authentication';
import { router, useGlobalSearchParams, usePathname } from 'expo-router';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Alert, AppState, Platform } from 'react-native';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { useDb } from '@/context/DbContext';
@@ -43,8 +42,8 @@ type AuthContextType = {
// Autofill methods
shouldShowAutofillReminder: boolean;
markAutofillConfigured: () => Promise<void>;
// Return URL methods
returnUrl: { path: string; params?: object } | null;
// Return URL methods (basic return URL and deep link URL which if set acts as an override)
returnUrl: { path: string; params?: Record<string, string> | undefined } | null;
setReturnUrl: (url: { path: string; params?: object } | null) => void;
}
@@ -428,21 +427,23 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
// Handle app state changes
useEffect(() => {
const subscription = AppState.addEventListener('change', async (nextAppState) => {
const appstateSubscription = AppState.addEventListener('change', async (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
/**
* App coming to foreground
* Skip vault re-initialization checks during unlock, login, initialize, and reinitialize flows to prevent race conditions
* where the AppState listener fires during app initialization, especially on iOS release builds.
*/
if (!pathname?.includes('unlock') && !pathname?.includes('login') && !pathname?.includes('initialize') && !pathname?.includes('reinitialize')) {
if (!pathname?.startsWith('unlock') && !pathname?.startsWith('login') && !pathname?.startsWith('initialize') && !pathname?.startsWith('reinitialize')) {
try {
// Check if vault is unlocked.
const isUnlocked = await isVaultUnlocked();
if (!isUnlocked) {
console.log('-------- vault NOT unlocked trigger detection here ---------------------')
// Get current full URL including query params
const currentRoute = lastRouteRef.current;
if (currentRoute?.path) {
console.log('setting return url to current route so reinitialize takes care of it..?:', currentRoute.path, currentRoute.params);
setReturnUrl({
path: currentRoute.path,
params: currentRoute.params
@@ -462,7 +463,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
});
return (): void => {
subscription.remove();
appstateSubscription.remove();
};
}, [isVaultUnlocked, pathname]);

View File

@@ -0,0 +1,232 @@
import { Href } from 'expo-router';
import { Linking } from 'react-native';
/**
* Normalize a deep link or path to ensure it has the correct /(tabs)/ prefix.
* Exported for use in _layout.tsx and other navigation logic.
*
* Supports:
* - Action-based URLs: aliasvault://open/mobile-unlock/[id]
* - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page]
*/
export function normalizeDeepLinkPath(urlOrPath: string): string {
// Remove all URL schemes first
let path = urlOrPath
.replace('net.aliasvault.app://', '')
.replace('aliasvault://', '')
.replace('exp+aliasvault://', '');
// If it already has /(tabs)/ prefix, return as is
if (path.startsWith('/(tabs)/')) {
return path;
}
// Handle action-based paths: $/mobile-unlock/[requestId]
if (path.startsWith('open/mobile-unlock/')) {
return `/(tabs)/settings/mobile-unlock/${path.split('/')[2]}`;
}
// Handle credential paths
if (path.startsWith('credentials/') || path.includes('/credentials/')) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return `/(tabs)${path}`;
}
// Handle settings paths
if (path.startsWith('settings/') || path.startsWith('/settings')) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return `/(tabs)${path}`;
}
// If path starts with /, add /(tabs) prefix
if (path.startsWith('/')) {
return `/(tabs)${path}`;
}
return path;
}
/**
* Post-unlock navigation options.
*/
export type PostUnlockNavigationOptions = {
/**
* Pending deep link URL to process after unlock (from QR code scan during boot).
*/
pendingDeepLink?: string | null;
/**
* Return URL from app context (for reinitialize flow).
*/
returnUrl?: { path: string; params?: Record<string, string> } | null;
/**
* Router instance for navigation.
*/
router: {
replace: (href: Href) => void;
push: (href: Href) => void;
};
/**
* Clear the return URL from app context.
*/
clearReturnUrl?: () => void;
}
/**
* Centralized post-unlock navigation logic.
* Handles pending deep links, return URLs, and default navigation.
* This ensures consistent navigation behavior across all unlock flows:
* - initialize.tsx (cold boot with biometric unlock)
* - unlock.tsx (manual password/PIN unlock)
* - reinitialize.tsx (timeout recovery with biometric unlock)
*/
export class PostUnlockNavigation {
/**
* Navigate to the appropriate destination after successful vault unlock.
* Priority order:
* 1. Pending deep link (from QR code scan during cold boot)
* 2. Return URL (from reinitialize flow)
* 3. Default credentials tab
*/
static navigate(options: PostUnlockNavigationOptions): void {
const { pendingDeepLink, returnUrl, router, clearReturnUrl } = options;
// Priority 1: Handle pending deep link (e.g. from QR code scan or native autofill interface)
if (pendingDeepLink) {
console.log('[_postunlocknavigation] navigate with pendingDeepLink:', pendingDeepLink);
this.handlePendingDeepLink(pendingDeepLink, router);
return;
}
// Priority 2: Handle return URL (from reinitialize flow)
if (returnUrl?.path) {
console.log('[_postunlocknavigation] navigate with returnUrl:', returnUrl);
this.handleReturnUrl(returnUrl, router);
if (clearReturnUrl) {
clearReturnUrl();
}
return;
}
// Priority 3: Default navigation to credentials
router.replace('/(tabs)/credentials');
}
/**
* Handle pending deep link after successful unlock.
*/
private static handlePendingDeepLink(
deepLink: string,
router: PostUnlockNavigationOptions['router']
): void {
// Normalize the deep link to get the correct path with /(tabs)/ prefix
const normalizedPath = normalizeDeepLinkPath(deepLink);
// Check if this is a mobile-login QR scanner route (already formatted with query params)
if (normalizedPath.includes('/qr-scanner?url=')) {
// First navigate to settings tab
router.replace('/(tabs)/settings');
// Then navigate to qr-scanner
setTimeout(() => {
router.push(normalizedPath as Href);
}, 0);
return;
}
// Check if this is a detail route (credentials or settings sub-pages)
const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/');
const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/');
if (isCredentialRoute) {
// Navigate to credentials tab first, then push detail page
router.replace('/(tabs)/credentials');
setTimeout(() => {
router.push(normalizedPath as Href);
}, 0);
} else if (isSettingsRoute) {
// Navigate to settings tab first, then push detail page
router.replace('/(tabs)/settings');
setTimeout(() => {
router.push(normalizedPath as Href);
}, 0);
} else {
// Direct navigation for root tab routes
router.replace(normalizedPath as Href);
}
}
/**
* Handle return URL navigation (from reinitialize flow).
*/
private static handleReturnUrl(
returnUrl: { path: string; params?: Record<string, string> | undefined },
router: PostUnlockNavigationOptions['router']
): void {
// Normalize the path using centralized function
const normalizedPath = normalizeDeepLinkPath(returnUrl.path);
const params = returnUrl.params || {};
// Check if this is a detail route (has a sub-page after the tab)
const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/');
const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') &&
!normalizedPath.endsWith('/(tabs)/settings');
if (isCredentialRoute) {
// Navigate to credentials tab first, then push detail page
router.replace('/(tabs)/credentials');
setTimeout(() => {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath;
router.push(targetUrl as Href);
}, 0);
} else if (isSettingsRoute) {
// Navigate to settings tab first, then push detail page
router.replace('/(tabs)/settings');
setTimeout(() => {
const queryParams = new URLSearchParams(params as Record<string, string>).toString();
const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath;
router.push(targetUrl as Href);
}, 0);
} else {
// Direct navigation for root tab routes
router.replace({
pathname: normalizedPath as '/',
params: params as Record<string, string>
});
}
}
/**
* Get the initial pending deep link URL if available.
* This should be called during app boot to check for QR code scans.
*/
static async getInitialPendingDeepLink(): Promise<string | null> {
try {
const initialUrl = await Linking.getInitialURL();
if (!initialUrl) {
return null;
}
// Check if it's a supported deep link type
const path = initialUrl
.replace('net.aliasvault.app://', '')
.replace('aliasvault://', '')
.replace('exp+aliasvault://', '');
if (path.startsWith('mobile-login/') || path.includes('credentials/')) {
return initialUrl;
}
return null;
} catch (error) {
console.error('Error getting initial deep link:', error);
return null;
}
}
}