mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-11 08:48:33 -04:00
Refactor deep linking to work better with vault lock flow (#1347)
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
@@ -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({
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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') }} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
69
apps/mobile-app/app/open/[...path].tsx
Normal file
69
apps/mobile-app/app/open/[...path].tsx
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
232
apps/mobile-app/utils/PostUnlockNavigation.ts
Normal file
232
apps/mobile-app/utils/PostUnlockNavigation.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user