Refactor nav structure, make credential create deep link work (#771)

This commit is contained in:
Leendert de Borst
2025-04-29 13:37:20 +02:00
parent d554f0f3cc
commit caef74477b
22 changed files with 128 additions and 60 deletions

View File

@@ -5,9 +5,11 @@
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "AliasVault",
"scheme": "net.aliasvault.app",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"deepLinking": true,
"platforms": ["ios", "android", "web"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "net.aliasvault.app"

View File

@@ -57,21 +57,21 @@ export default function TabLayout() {
}),
}}>
<Tabs.Screen
name="(credentials)"
name="credentials"
options={{
title: 'Credentials',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="key.fill" color={color} />,
}}
/>
<Tabs.Screen
name="(emails)"
name="emails"
options={{
title: 'Emails',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="envelope.fill" color={color} />,
}}
/>
<Tabs.Screen
name="(settings)"
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color }) => (

View File

@@ -6,15 +6,15 @@ import { ThemedView } from '@/components/ThemedView';
import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView';
import { useColors } from '@/hooks/useColorScheme';
import { useDb } from '@/context/DbContext';
import { Credential, Alias } from '@/utils/types/Credential';
import { Credential } from '@/utils/types/Credential';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import Toast from 'react-native-toast-message';
import { Gender } from "../../../utils/generators/Identity/types/Gender";
import { Gender } from "@/utils/generators/Identity/types/Gender";
type CredentialMode = 'random' | 'manual';
export default function AddEditCredentialScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { id, serviceUrl } = useLocalSearchParams<{ id: string, serviceUrl?: string }>();
const router = useRouter();
const colors = useColors();
const dbContext = useDb();
@@ -38,33 +38,60 @@ export default function AddEditCredentialScreen() {
},
});
// Set navigation options
useEffect(() => {
navigation.setOptions({
headerLeft: () => (
function extractServiceNameFromUrl(url: string): string {
try {
const urlObj = new URL(url);
const hostParts = urlObj.hostname.split('.');
// Remove common subdomains
const commonSubdomains = ['www', 'app', 'login', 'auth', 'account', 'portal'];
while (hostParts.length > 2 && commonSubdomains.includes(hostParts[0].toLowerCase())) {
hostParts.shift();
}
// For domains like google.com, return Google.com
if (hostParts.length <= 2) {
const domain = hostParts.join('.');
return domain.charAt(0).toUpperCase() + domain.slice(1);
}
// For domains like app.example.com, return Example.com
const mainDomain = hostParts.slice(-2).join('.');
return mainDomain.charAt(0).toUpperCase() + mainDomain.slice(1);
} catch (e) {
// If URL parsing fails, return the original URL
return url;
}
}
// Set navigation options
useEffect(() => {
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => router.back()}
style={{ padding: 10, paddingLeft: 0 }}
>
<ThemedText style={{ color: colors.primary }}>Cancel</ThemedText>
</TouchableOpacity>
),
headerRight: () => (
<View style={{ flexDirection: 'row' }}>
<TouchableOpacity
onPress={() => router.back()}
style={{ padding: 10, paddingLeft: 0 }}
onPress={handleSave}
style={{ padding: 10, paddingRight: 0 }}
>
<ThemedText style={{ color: colors.primary }}>Cancel</ThemedText>
<MaterialIcons
name="save"
size={24}
color={colors.primary}
/>
</TouchableOpacity>
),
headerRight: () => (
<View style={{ flexDirection: 'row' }}>
<TouchableOpacity
onPress={handleSave}
style={{ padding: 10, paddingRight: 0 }}
>
<MaterialIcons
name="save"
size={24}
color={colors.primary}
/>
</TouchableOpacity>
</View>
),
});
}, [navigation, credential, mode]);
</View>
),
});
}, [navigation, credential, mode]);
const isEditMode = !!id;
@@ -74,6 +101,26 @@ export default function AddEditCredentialScreen() {
}
}, [id]);
useEffect(() => {
// If serviceUrl is provided, extract the service name from the URL and prefill the form values.
// This is used when the user opens the app from a deep link (e.g. from iOS autofill extension).
if (serviceUrl) {
// Decode the URL-encoded service URL
const decodedUrl = decodeURIComponent(serviceUrl);
// Extract service name from URL
const serviceName = extractServiceNameFromUrl(decodedUrl);
// Set the form values
// Note: You'll need to implement this based on your form state management
setCredential(prev => ({
...prev,
ServiceUrl: decodedUrl,
ServiceName: serviceName,
// ... other form fields
}));
}
}, [serviceUrl]);
const loadExistingCredential = async () => {
try {
setIsLoading(true);
@@ -244,6 +291,8 @@ export default function AddEditCredentialScreen() {
},
});
console.log('credential serviceName: ', credential.ServiceName);
return (
<ThemedSafeAreaView style={styles.container}>
<ThemedView style={styles.content}>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import EmailDetailsScreen from '../../(emails)/[id]';
import EmailDetailsScreen from '../../emails/[id]';
/**
* CredentialEmailPreviewScreen Component

View File

@@ -31,7 +31,7 @@ export default function CredentialsScreen() {
const headerButtons = [{
icon: 'add' as const,
position: 'right' as const,
onPress: () => router.push('/(tabs)/(credentials)/add-edit')
onPress: () => router.push('/(tabs)/credentials/add-edit')
}];
useEffect(() => {
@@ -44,7 +44,7 @@ export default function CredentialsScreen() {
});
const sub = emitter.addListener('tabPress', (routeName: string) => {
if (routeName === '(credentials)' && isTabFocused) {
if (routeName === 'credentials' && isTabFocused) {
console.log('Credentials tab re-pressed while focused: reset screen');
setSearchQuery(''); // Reset search
setRefreshing(false); // Reset refreshing

View File

@@ -168,7 +168,7 @@ export default function EmailDetailsScreen() {
const handleOpenCredential = () => {
if (associatedCredential) {
router.push(`/(tabs)/(credentials)/${associatedCredential.Id}`);
router.push(`/(tabs)/credentials/${associatedCredential.Id}`);
}
};

View File

@@ -62,7 +62,7 @@ export default function EmailsScreen() {
});
const sub = emitter.addListener('tabPress', (routeName: string) => {
if (routeName === '(emails)' && isTabFocused) {
if (routeName === 'emails' && isTabFocused) {
console.log('Emails tab re-pressed while focused: reset screen');
// Scroll to top
scrollViewRef.current?.scrollTo({ y: 0, animated: true });

View File

@@ -54,15 +54,15 @@ export default function SettingsScreen() {
};
const handleVaultUnlockPress = () => {
router.push('/(tabs)/(settings)/vault-unlock');
router.push('/(tabs)/settings/vault-unlock');
};
const handleAutoLockPress = () => {
router.push('/(tabs)/(settings)/auto-lock');
router.push('/(tabs)/settings/auto-lock');
};
const handleIosAutofillPress = () => {
router.push('/(tabs)/(settings)/ios-autofill');
router.push('/(tabs)/settings/ios-autofill');
};
const styles = StyleSheet.create({

View File

@@ -62,7 +62,7 @@ export default function SyncScreen() {
console.log('FaceID unlock successful, navigating to credentials');
// Navigate to credentials
router.replace('/(tabs)/(credentials)');
router.replace('/(tabs)/credentials');
return;
}

View File

@@ -62,7 +62,7 @@ export default function UnlockScreen() {
// Initialize the database with the vault response and password
if (await testDatabaseConnection(passwordHashBase64)) {
// Navigate to credentials
router.replace('/(tabs)/(credentials)');
router.replace('/(tabs)/credentials');
}
else {
Alert.alert('Error', 'Incorrect password. Please try again.');

View File

@@ -69,7 +69,7 @@ export function CredentialCard({ credential }: CredentialCardProps) {
style={styles.credentialCard}
onPress={() => {
Keyboard.dismiss();
router.push(`/(tabs)/(credentials)/${credential.Id}`);
router.push(`/(tabs)/credentials/${credential.Id}`);
}}
activeOpacity={0.7}
>

View File

@@ -79,7 +79,7 @@ export function EmailCard({ email }: EmailCardProps) {
return (
<TouchableOpacity
style={styles.emailCard}
onPress={() => router.push(`/(tabs)/(emails)/${email.id}`)}
onPress={() => router.push(`/(tabs)/emails/${email.id}`)}
activeOpacity={0.7}
>
<View style={styles.emailHeader}>

View File

@@ -193,7 +193,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
const emailPrefix = email.split('@')[0];
Linking.openURL(`https://spamok.com/${emailPrefix}/${mail.id}`);
} else {
router.push(`/(tabs)/(credentials)/email/${mail.id}`);
router.push(`/(tabs)/credentials/email/${mail.id}`);
}
}}
>

View File

@@ -88,10 +88,16 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
guard let viewModel = self.viewModel else { return }
// Instead of directly filtering credentials, just set the search text
let matchedDomains = serviceIdentifiers.map { $0.identifier.lowercased() }
if let firstDomain = matchedDomains.first {
// Set the search text to the first domain which will auto filter the credentials
// to show the most likely credentials first as suggestion.
viewModel.setSearchFilter(firstDomain)
// Set the service URL to the first domain which will be used to pass onto the
// add credential view when the user taps the "+" button and prefill it with the
// domain name.
viewModel.serviceUrl = firstDomain
}
}

View File

@@ -51,10 +51,12 @@ public struct CredentialProviderView: View {
if !viewModel.isChoosingTextToInsert {
VStack(spacing: 12) {
Button(action: {
if let url = URL(string: "net.aliasvault.app://addCredential?service=example.com") {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
if let serviceUrl = viewModel.serviceUrl {
let encodedUrl = serviceUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
if let url = URL(string: "net.aliasvault.app://credentials/add-edit?serviceUrl=\(encodedUrl)") {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
//viewModel.showAddCredential = true
}) {
HStack {
Image(systemName: "plus.circle.fill")
@@ -101,10 +103,12 @@ public struct CredentialProviderView: View {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button("Add") {
if let url = URL(string: "net.aliasvault.app://addCredential?service=example.com") {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
if let serviceUrl = viewModel.serviceUrl {
let encodedUrl = serviceUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
if let url = URL(string: "net.aliasvault.app://credentials/add-edit?serviceUrl=\(encodedUrl)") {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
//viewModel.showAddCredential = true
}
.foregroundColor(ColorConstants.Light.primary)
}
@@ -115,14 +119,14 @@ public struct CredentialProviderView: View {
}
.actionSheet(isPresented: $viewModel.showSelectionOptions) {
// Define all text strings
guard let credential = viewModel.selectedCredential else {
return ActionSheet(title: Text("Select Login Method"), message: Text("No credential selected."), buttons: [.cancel()])
}
var buttons: [ActionSheet.Button] = []
if viewModel.isChoosingTextToInsert {
if let username = credential.username, !username.isEmpty {
buttons.append(.default(Text("Username: \(username)")) {
@@ -135,7 +139,7 @@ public struct CredentialProviderView: View {
viewModel.selectEmail()
})
}
buttons.append(.default(Text("Password")) {
viewModel.selectPassword()
})
@@ -193,6 +197,7 @@ public class CredentialProviderViewModel: ObservableObject {
@Published var showSelectionOptions = false
@Published var selectedCredential: Credential?
@Published public var isChoosingTextToInsert = false
@Published public var serviceUrl: String?
@Published var newUsername = ""
@Published var newPassword = ""
@@ -205,11 +210,16 @@ public class CredentialProviderViewModel: ObservableObject {
public init(
loader: @escaping () async throws -> [Credential],
selectionHandler: @escaping (String, String) -> Void,
cancelHandler: @escaping () -> Void
cancelHandler: @escaping () -> Void,
serviceUrl: String? = nil
) {
self.loader = loader
self.selectionHandler = selectionHandler
self.cancelHandler = cancelHandler
self.serviceUrl = serviceUrl
if let url = serviceUrl {
self.searchText = url
}
}
@MainActor
@@ -325,19 +335,19 @@ public class CredentialProviderViewModel: ObservableObject {
// If we have both options, show selection sheet
showSelectionOptions = true
}
func selectUsername() {
guard let credential = selectedCredential else { return }
selectionHandler(credential.username ?? "", "")
showSelectionOptions = false
}
func selectEmail() {
guard let credential = selectedCredential else { return }
selectionHandler(credential.alias?.email ?? "", "")
showSelectionOptions = false
}
func selectPassword() {
guard let credential = selectedCredential else { return }
selectionHandler(credential.password?.value ?? "", "")
@@ -551,7 +561,8 @@ class PreviewCredentialProviderViewModel: CredentialProviderViewModel {
return previewCredentials
},
selectionHandler: { _, _ in },
cancelHandler: {}
cancelHandler: {},
serviceUrl: nil
)
credentials = previewCredentials