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;
+ }
+ }
+}