From 359f91105710c5be845bfba2a78b280a4e210952 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 18 Nov 2025 16:51:14 +0100 Subject: [PATCH] Refactor deep linking to work better with vault lock flow (#1347) --- apps/mobile-app/app.json | 2 +- .../app/(tabs)/settings/_layout.tsx | 4 +- .../[id].tsx} | 34 +-- .../result.tsx} | 10 +- .../app/(tabs)/settings/qr-scanner.tsx | 22 +- apps/mobile-app/app/+not-found.tsx | 15 +- apps/mobile-app/app/_layout.tsx | 51 +--- apps/mobile-app/app/initialize.tsx | 108 ++++---- apps/mobile-app/app/open/[...path].tsx | 69 ++++++ apps/mobile-app/app/reinitialize.tsx | 109 +++----- apps/mobile-app/app/unlock.tsx | 36 ++- apps/mobile-app/context/AppContext.tsx | 8 +- apps/mobile-app/context/AuthContext.tsx | 13 +- apps/mobile-app/utils/PostUnlockNavigation.ts | 232 ++++++++++++++++++ 14 files changed, 472 insertions(+), 241 deletions(-) rename apps/mobile-app/app/(tabs)/settings/{qr-confirm.tsx => mobile-unlock/[id].tsx} (87%) rename apps/mobile-app/app/(tabs)/settings/{qr-result.tsx => mobile-unlock/result.tsx} (92%) create mode 100644 apps/mobile-app/app/open/[...path].tsx create mode 100644 apps/mobile-app/utils/PostUnlockNavigation.ts diff --git a/apps/mobile-app/app.json b/apps/mobile-app/app.json index 234317d01..cbb2adc1b 100644 --- a/apps/mobile-app/app.json +++ b/apps/mobile-app/app.json @@ -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": [ diff --git a/apps/mobile-app/app/(tabs)/settings/_layout.tsx b/apps/mobile-app/app/(tabs)/settings/_layout.tsx index 535e74441..47f78bc03 100644 --- a/apps/mobile-app/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/settings/_layout.tsx @@ -134,14 +134,14 @@ export default function SettingsLayout(): React.ReactNode { }} /> (); + const { id } = useLocalSearchParams(); const [isProcessing, setIsProcessing] = useState(false); /** * Handle mobile login QR code. */ - const handleMobileLogin = async (requestId: string) : Promise => { + const handleMobileLogin = async (id: string) : Promise => { 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 => { - 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 ( diff --git a/apps/mobile-app/app/(tabs)/settings/qr-result.tsx b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx similarity index 92% rename from apps/mobile-app/app/(tabs)/settings/qr-result.tsx rename to apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx index ad3219502..e6f21cdee 100644 --- a/apps/mobile-app/app/(tabs)/settings/qr-result.tsx +++ b/apps/mobile-app/app/(tabs)/settings/mobile-unlock/result.tsx @@ -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({ diff --git a/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx index d3ca871bc..e655e34e2 100644 --- a/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx +++ b/apps/mobile-app/app/(tabs)/settings/qr-scanner.tsx @@ -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; } diff --git a/apps/mobile-app/app/+not-found.tsx b/apps/mobile-app/app/+not-found.tsx index 5802a8ef0..e890148dd 100644 --- a/apps/mobile-app/app/+not-found.tsx +++ b/apps/mobile-app/app/+not-found.tsx @@ -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 ( <> diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx index 25f3b541b..6d5e334d2 100644 --- a/apps/mobile-app/app/_layout.tsx +++ b/apps/mobile-app/app/_layout.tsx @@ -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(null); const hasBooted = useRef(false); - const processedDeepLinks = useRef(new Set()); 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 ( {/* Loading state while booting */} + ); } diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index 3b12633a7..ce4cd8cee 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -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. diff --git a/apps/mobile-app/app/open/[...path].tsx b/apps/mobile-app/app/open/[...path].tsx new file mode 100644 index 000000000..9efa503fe --- /dev/null +++ b/apps/mobile-app/app/open/[...path].tsx @@ -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; +} diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx index 5bd7eb703..64af76a85 100644 --- a/apps/mobile-app/app/reinitialize.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -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 - }); - app.setReturnUrl(null); - return; - } - - // Handle detail routes - const params = app.returnUrl.params as Record; - 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; - - 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 - }); - } - // 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. diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 688b7fe64..345854a85 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -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'), diff --git a/apps/mobile-app/context/AppContext.tsx b/apps/mobile-app/context/AppContext.tsx index b62db73fe..61a9d941e 100644 --- a/apps/mobile-app/context/AppContext.tsx +++ b/apps/mobile-app/context/AppContext.tsx @@ -35,8 +35,8 @@ type AppContextType = { shouldShowAutofillReminder: boolean; markAutofillConfigured: () => Promise; // Return URL methods - returnUrl: { path: string; params?: object } | null; - setReturnUrl: (url: { path: string; params?: object } | null) => void; + returnUrl: { path: string; params?: Record | undefined } | null; + setReturnUrl: (url: { path: string; params?: Record } | 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 ( diff --git a/apps/mobile-app/context/AuthContext.tsx b/apps/mobile-app/context/AuthContext.tsx index 5f9d4119e..c58f9dbe9 100644 --- a/apps/mobile-app/context/AuthContext.tsx +++ b/apps/mobile-app/context/AuthContext.tsx @@ -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; - // 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 | 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]); diff --git a/apps/mobile-app/utils/PostUnlockNavigation.ts b/apps/mobile-app/utils/PostUnlockNavigation.ts new file mode 100644 index 000000000..500b13941 --- /dev/null +++ b/apps/mobile-app/utils/PostUnlockNavigation.ts @@ -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 } | 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 | 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).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).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 + }); + } + } + + /** + * 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 { + 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; + } + } +}