diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt index 59731197a..6d068e88e 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt @@ -384,8 +384,9 @@ class AutofillService : AutofillService() { val appInfo = fieldFinder.getAppInfo() val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" - // Create deep link URL - val deepLinkUrl = "aliasvault://items/add-edit-page?itemUrl=$encodedUrl" + // Open the action picker so the user can choose between linking this app + // to an existing credential or creating a new one. + val deepLinkUrl = "aliasvault://items/autofill-open-app?itemUrl=$encodedUrl" // Add a click listener to open AliasVault app with deep link val intent = Intent(Intent.ACTION_VIEW).apply { @@ -553,10 +554,11 @@ class AutofillService : AutofillService() { val dataSetBuilder = Dataset.Builder(presentation) - // Create deep link URL to open the items page + // Open the action picker so the user can choose between linking this app + // to an existing credential or creating a new one. val appInfo = fieldFinder.getAppInfo() val encodedUrl = appInfo?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" - val deepLinkUrl = "aliasvault://items?itemUrl=$encodedUrl" + val deepLinkUrl = "aliasvault://items/autofill-open-app?itemUrl=$encodedUrl" // Add a click listener to open AliasVault app with deep link val intent = Intent(Intent.ACTION_VIEW).apply { diff --git a/apps/mobile-app/android/app/src/main/res/values/strings.xml b/apps/mobile-app/android/app/src/main/res/values/strings.xml index 08025064f..22e5ac4eb 100644 --- a/apps/mobile-app/android/app/src/main/res/values/strings.xml +++ b/apps/mobile-app/android/app/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ Back An unknown error occurred Failed to retrieve, open app - No match found, create new? + No match found Open app Vault locked Store Encryption Key diff --git a/apps/mobile-app/app/(tabs)/items/_layout.tsx b/apps/mobile-app/app/(tabs)/items/_layout.tsx index d975f5a15..695460289 100644 --- a/apps/mobile-app/app/(tabs)/items/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/items/_layout.tsx @@ -52,6 +52,28 @@ export default function ItemsLayout(): React.ReactNode { ...defaultHeaderOptions, }} /> + + + - {t('items.switchBackToBrowser')} + {t('items.switchBackToOriginalApp')} diff --git a/apps/mobile-app/app/(tabs)/items/autofill-link-existing.tsx b/apps/mobile-app/app/(tabs)/items/autofill-link-existing.tsx new file mode 100644 index 000000000..ee5b8bf60 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/items/autofill-link-existing.tsx @@ -0,0 +1,384 @@ +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlatList, Platform, StyleSheet, TextInput, TouchableOpacity, View, type ListRenderItem } from 'react-native'; +import Toast from 'react-native-toast-message'; + +import type { Item, ItemField } from '@/utils/dist/core/models/vault'; +import { + FieldKey, + FieldTypes, + ItemTypes, + getFieldValue, + getFieldValues, +} from '@/utils/dist/core/models/vault'; + +import { useColors } from '@/hooks/useColorScheme'; +import { useVaultMutate } from '@/hooks/useVaultMutate'; + +import { ItemIcon } from '@/components/items/ItemIcon'; +import { ThemedSafeAreaView } from '@/components/themed/ThemedSafeAreaView'; +import { ThemedText } from '@/components/themed/ThemedText'; +import { ThemedView } from '@/components/themed/ThemedView'; +import { RobustPressable } from '@/components/ui/RobustPressable'; +import { useDb } from '@/context/DbContext'; +import { useDialog } from '@/context/DialogContext'; + +/** + * Screen for picking an existing credential to attach the autofill + * URL/package identifier to. After saving, the user is navigated to a + * confirmation screen so they know the link was created and that the + * next autofill attempt for the same app should succeed. + */ +export default function AutofillLinkExistingScreen(): React.ReactNode { + const router = useRouter(); + const navigation = useNavigation(); + const colors = useColors(); + const { t } = useTranslation(); + const dbContext = useDb(); + const { executeVaultMutation } = useVaultMutate(); + const { showConfirm } = useDialog(); + const { itemUrl } = useLocalSearchParams<{ itemUrl?: string }>(); + + const decodedAppInfo = useMemo(() => { + if (!itemUrl) { + return ''; + } + try { + return decodeURIComponent(itemUrl); + } catch { + return itemUrl; + } + }, [itemUrl]); + + const [items, setItems] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + /** + * Load Login-typed items from the local vault. We don't need credit + * cards / notes here because the URL field only applies to logins. + */ + useEffect(() => { + /** + * Fetch login items from the vault. + */ + const load = async (): Promise => { + try { + const all = await dbContext.sqliteClient!.items.getAll(); + const logins = all.filter(item => item.ItemType === ItemTypes.Login); + setItems(logins); + } catch (err) { + console.error('Error loading items for autofill link flow:', err); + } + }; + if (dbContext.dbAvailable) { + load(); + } + }, [dbContext.dbAvailable, dbContext.sqliteClient]); + + /** + * Header title — back navigation is handled by the stack's default + * back arrow, so we don't need a custom right-side button here. + */ + useEffect(() => { + navigation.setOptions({ + title: t('items.autofillLinkExisting.title'), + }); + }, [navigation, t]); + + /** + * Filter items using a substring match across name, username, email, + * and any of the credential's existing URLs (multi-value aware). + */ + const filteredItems = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) { + return items; + } + const words = q.split(/\s+/).filter(Boolean); + return items.filter(item => { + const haystacks: string[] = [ + item.Name?.toLowerCase() ?? '', + getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() ?? '', + getFieldValue(item, FieldKey.LoginEmail)?.toLowerCase() ?? '', + ...getFieldValues(item, FieldKey.LoginUrl).map(u => u.toLowerCase()), + ]; + return words.every(word => haystacks.some(h => h.includes(word))); + }); + }, [items, searchQuery]); + + /** + * Append the autofill URL/package to the chosen item's `login.url` + * multi-value field and persist via the vault mutation pipeline. + */ + const linkItem = useCallback(async (item: Item): Promise => { + if (!decodedAppInfo) { + return; + } + setIsSaving(true); + try { + const existingField = item.Fields.find(f => f.FieldKey === FieldKey.LoginUrl); + const existingValues = existingField + ? Array.isArray(existingField.Value) ? existingField.Value : [existingField.Value] + : []; + + /** + * After a successful link the user shouldn't be able to swipe/back + * their way into the "what would you like to do?" screen — they're + * done. Pop everything in the items stack so the success screen + * sits directly on top of the items home. + */ + const navigateToSuccess = (): void => { + router.dismissTo('/(tabs)/items'); + router.push({ + pathname: '/(tabs)/items/autofill-url-added', + params: { itemName: item.Name ?? '', itemUrl: decodedAppInfo }, + }); + }; + + /* + * Avoid duplicates — if the URL is already linked, skip the write + * and just send the user to the confirmation screen. + */ + if (existingValues.includes(decodedAppInfo)) { + navigateToSuccess(); + return; + } + + const newValues = [...existingValues.filter(v => v && v.length > 0), decodedAppInfo]; + + let updatedFields: ItemField[]; + if (existingField) { + updatedFields = item.Fields.map(f => + f.FieldKey === FieldKey.LoginUrl ? { ...f, Value: newValues } : f + ); + } else { + const newField: ItemField = { + FieldKey: FieldKey.LoginUrl, + Label: FieldKey.LoginUrl, + FieldType: FieldTypes.URL, + Value: newValues, + IsHidden: false, + DisplayOrder: 100, + IsCustomField: false, + EnableHistory: false, + }; + updatedFields = [...item.Fields, newField]; + } + + const itemToSave: Item = { + ...item, + Fields: updatedFields, + UpdatedAt: new Date().toISOString(), + }; + + await executeVaultMutation(async () => { + await dbContext.sqliteClient!.items.update(itemToSave); + }); + + navigateToSuccess(); + } catch (err) { + console.error('Error linking URL to existing item:', err); + Toast.show({ + type: 'error', + text1: t('common.error'), + text2: t('common.errors.unknownErrorTryAgain'), + }); + } finally { + setIsSaving(false); + } + }, [decodedAppInfo, dbContext.sqliteClient, executeVaultMutation, router, t]); + + /** + * Confirm with the user before mutating the credential. + */ + const handleSelectItem = useCallback((item: Item) => { + if (isSaving) { + return; + } + showConfirm( + t('items.autofillLinkExisting.confirmTitle'), + t('items.autofillLinkExisting.confirmMessage', { + url: decodedAppInfo, + name: item.Name ?? t('items.untitled'), + }), + t('common.confirm'), + () => linkItem(item), + ); + }, [decodedAppInfo, linkItem, showConfirm, t, isSaving]); + + const styles = StyleSheet.create({ + container: { + flex: 1, + }, + emptyState: { + alignItems: 'center', + paddingHorizontal: 24, + paddingVertical: 40, + }, + emptyText: { + color: colors.textMuted, + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + }, + headerArea: { + paddingBottom: 8, + paddingHorizontal: 16, + paddingTop: 12, + }, + introText: { + color: colors.textMuted, + fontSize: 14, + lineHeight: 20, + marginBottom: 12, + }, + itemDetail: { + color: colors.textMuted, + fontSize: 13, + marginTop: 2, + }, + itemName: { + fontSize: 16, + fontWeight: '600', + }, + itemRow: { + alignItems: 'center', + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 10, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + marginHorizontal: 16, + marginVertical: 4, + padding: 12, + }, + itemTextWrapper: { + flex: 1, + }, + listContent: { + paddingBottom: 32, + }, + searchClearButton: { + padding: 4, + position: 'absolute', + right: 8, + top: 4, + }, + searchClearText: { + color: colors.textMuted, + fontSize: 20, + }, + searchContainer: { + marginTop: 12, + position: 'relative', + }, + searchIcon: { + left: 12, + position: 'absolute', + top: 11, + zIndex: 1, + }, + searchInput: { + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 8, + borderWidth: 1, + color: colors.text, + fontSize: 16, + height: 40, + paddingLeft: 40, + paddingRight: Platform.OS === 'android' ? 40 : 12, + }, + }); + + /** + * Render an individual credential row. + */ + const renderItem: ListRenderItem = useCallback((info) => { + const row = info.item; + const username = getFieldValue(row, FieldKey.LoginUsername); + const email = getFieldValue(row, FieldKey.LoginEmail); + const detail = username || email || ''; + + return ( + handleSelectItem(row)} + testID={`autofill-link-item-${row.Id}`} + > + + + + {row.Name || t('items.untitled')} + + {detail.length > 0 && ( + + {detail} + + )} + + + + ); + }, [colors.primary, handleSelectItem, styles, t]); + + return ( + + + + {t('items.autofillLinkExisting.intro', { target: decodedAppInfo })} + + + + + + {Platform.OS === 'android' && searchQuery.length > 0 && ( + setSearchQuery('')} + testID="autofill-link-clear-search" + > + × + + )} + + + + item.Id} + renderItem={renderItem} + contentContainerStyle={styles.listContent} + keyboardShouldPersistTaps="handled" + ListEmptyComponent={( + + + {searchQuery + ? t('items.noMatchingItemsSearch', { search: searchQuery }) + : t('items.noItemsFound')} + + + )} + /> + + ); +} diff --git a/apps/mobile-app/app/(tabs)/items/autofill-open-app.tsx b/apps/mobile-app/app/(tabs)/items/autofill-open-app.tsx new file mode 100644 index 000000000..c352294b3 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/items/autofill-open-app.tsx @@ -0,0 +1,225 @@ +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppState, StyleSheet, View } from 'react-native'; + +import { useColors } from '@/hooks/useColorScheme'; + +import { ThemedSafeAreaView } from '@/components/themed/ThemedSafeAreaView'; +import { ThemedText } from '@/components/themed/ThemedText'; +import { ThemedView } from '@/components/themed/ThemedView'; +import { RobustPressable } from '@/components/ui/RobustPressable'; + +/** + * Landing screen shown when the user opens AliasVault from the Android + * autofill popup, either via the "Open app" button or via "No match + * found". The screen is intentionally neutral so it works in both + * cases (no match, or there are matches but the user wants to do + * something else, like add a second account or link a new app + * identifier to an existing credential). + * + * Lets the user choose between: + * - Linking an existing credential (so the app's package/URL is added + * to that credential and future autofill prompts succeed + * automatically), or + * - Creating a brand-new credential pre-filled with the app's URL. + * + * The user can dismiss via the system back arrow if neither applies. + */ +export default function AutofillOpenAppScreen(): React.ReactNode { + const router = useRouter(); + const colors = useColors(); + const { t } = useTranslation(); + const { itemUrl } = useLocalSearchParams<{ itemUrl?: string }>(); + + const decodedAppInfo = useMemo(() => { + if (!itemUrl) { + return ''; + } + try { + return decodeURIComponent(itemUrl); + } catch { + return itemUrl; + } + }, [itemUrl]); + + /** + * Navigate to the credential picker so the user can attach this URL + * to an already-existing credential. + */ + const handleFindExisting = useCallback(() => { + router.push( + `/(tabs)/items/autofill-link-existing?itemUrl=${encodeURIComponent(decodedAppInfo)}` + ); + }, [router, decodedAppInfo]); + + /** + * Navigate to the existing add-edit-page deep-link target with the + * URL pre-populated, mirroring the previous behaviour. + */ + const handleCreateNew = useCallback(() => { + router.replace( + `/(tabs)/items/add-edit-page?itemUrl=${encodeURIComponent(decodedAppInfo)}` + ); + }, [router, decodedAppInfo]); + + /** + * Auto-dismiss when the app goes to the background — matches the + * pattern in autofill-item-created so users can't get stuck here. + */ + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (nextAppState === 'background') { + router.back(); + } + }); + return (): void => { + subscription.remove(); + }; + }, [router]); + + const styles = StyleSheet.create({ + actionDescription: { + color: colors.textMuted, + fontSize: 14, + lineHeight: 20, + marginTop: 4, + }, + actionRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 16, + }, + actionTextWrapper: { + flex: 1, + }, + actionTitle: { + fontSize: 16, + fontWeight: '600', + }, + appInfoBox: { + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 8, + borderWidth: 1, + marginBottom: 24, + paddingHorizontal: 12, + paddingVertical: 10, + width: '100%', + }, + appInfoLabel: { + color: colors.textMuted, + fontSize: 12, + marginBottom: 2, + textTransform: 'uppercase', + }, + appInfoValue: { + fontSize: 16, + fontWeight: '600', + }, + container: { + flex: 1, + }, + content: { + alignItems: 'stretch', + flex: 1, + paddingHorizontal: 20, + paddingTop: 24, + }, + introText: { + color: colors.textMuted, + fontSize: 15, + lineHeight: 22, + marginBottom: 20, + textAlign: 'center', + }, + optionCard: { + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 12, + borderWidth: 1, + marginBottom: 12, + padding: 16, + }, + optionIconWrapper: { + alignItems: 'center', + backgroundColor: colors.background, + borderRadius: 24, + height: 48, + justifyContent: 'center', + width: 48, + }, + optionPrimaryIconWrapper: { + alignItems: 'center', + backgroundColor: colors.primary + '20', + borderRadius: 24, + height: 48, + justifyContent: 'center', + width: 48, + }, + }); + + return ( + + + + {t('items.autofillOpenApp.description')} + + + {decodedAppInfo.length > 0 && ( + + + {t('items.autofillOpenApp.appOrUrlLabel')} + + + {decodedAppInfo} + + + )} + + + + + + + + + {t('items.autofillOpenApp.findExistingTitle')} + + + {t('items.autofillOpenApp.findExistingDescription')} + + + + + + + + + + + + + + {t('items.autofillOpenApp.createNewTitle')} + + + {t('items.autofillOpenApp.createNewDescription')} + + + + + + + + ); +} diff --git a/apps/mobile-app/app/(tabs)/items/autofill-url-added.tsx b/apps/mobile-app/app/(tabs)/items/autofill-url-added.tsx new file mode 100644 index 000000000..255f06807 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/items/autofill-url-added.tsx @@ -0,0 +1,153 @@ +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppState, StyleSheet, View } from 'react-native'; + +import { useColors } from '@/hooks/useColorScheme'; + +import { ThemedSafeAreaView } from '@/components/themed/ThemedSafeAreaView'; +import { ThemedText } from '@/components/themed/ThemedText'; +import { ThemedView } from '@/components/themed/ThemedView'; + +/** + * Confirmation screen shown after the user successfully linked an + * autofill URL/package to an existing credential. Mirrors the + * autofill-item-created pattern: auto-dismisses when the app is + * sent to background so the user can return to the original app + * and trigger autofill again. + */ +export default function AutofillUrlAddedScreen(): React.ReactNode { + const router = useRouter(); + const colors = useColors(); + const { t } = useTranslation(); + const { itemName, itemUrl } = useLocalSearchParams<{ + itemName?: string; + itemUrl?: string; + }>(); + + const decodedItemName = useMemo(() => { + if (!itemName) { + return t('items.untitled'); + } + try { + return decodeURIComponent(itemName); + } catch { + return itemName; + } + }, [itemName, t]); + + const decodedItemUrl = useMemo(() => { + if (!itemUrl) { + return ''; + } + try { + return decodeURIComponent(itemUrl); + } catch { + return itemUrl; + } + }, [itemUrl]); + + /* + * Auto-dismiss when backgrounded so when the user comes back to the + * app they see the items home rather than this success screen. The + * stack was reset before navigating here, so router.back() pops + * straight to the items list. + */ + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (nextAppState === 'background') { + router.back(); + } + }); + return (): void => { + subscription.remove(); + }; + }, [router]); + + const styles = StyleSheet.create({ + boldMessage: { + fontWeight: 'bold', + marginTop: 20, + }, + container: { + flex: 1, + }, + content: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: 24, + }, + detailBox: { + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 8, + borderWidth: 1, + marginBottom: 16, + paddingHorizontal: 12, + paddingVertical: 10, + width: '100%', + }, + detailLabel: { + color: colors.textMuted, + fontSize: 12, + marginBottom: 2, + textTransform: 'uppercase', + }, + detailValue: { + fontSize: 14, + fontWeight: '600', + }, + headerRightButton: { + padding: 10, + paddingRight: 0, + }, + iconContainer: { + marginBottom: 24, + }, + message: { + fontSize: 16, + lineHeight: 24, + marginBottom: 20, + textAlign: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 16, + textAlign: 'center', + }, + }); + + return ( + + + + + + + {t('items.autofillUrlAdded.title')} + + + {t('items.autofillUrlAdded.message', { name: decodedItemName })} + + + {decodedItemUrl.length > 0 && ( + + + {t('items.autofillOpenApp.appOrUrlLabel')} + + + {decodedItemUrl} + + + )} + + + {t('items.switchBackToOriginalApp')} + + + + ); +} diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index e48bbe50d..544358914 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -443,7 +443,26 @@ "vaultSyncedSuccessfully": "Vault synced successfully", "vaultUpToDate": "Vault is up-to-date", "offlineMessage": "You are offline. Please connect to the internet to sync your vault.", - "switchBackToBrowser": "Switch back to your browser to continue.", + "switchBackToOriginalApp": "Switch back to the original app to continue.", + "autofillOpenApp": { + "title": "Autofill request", + "description": "Choose an action for the app or website that requested autofill.", + "appOrUrlLabel": "App or URL", + "findExistingTitle": "Link to existing credential", + "findExistingDescription": "Select an existing credential from your vault to link to the address above.", + "createNewTitle": "Create new credential", + "createNewDescription": "Add a new credential to your vault for this app or URL." + }, + "autofillLinkExisting": { + "title": "Link to existing credential", + "intro": "Select an existing credential from your vault to link to \"{{target}}\".", + "confirmTitle": "Link credential?", + "confirmMessage": "Are you sure you want to link \"{{url}}\" to this credential?" + }, + "autofillUrlAdded": { + "title": "Credential linked", + "message": "Autofill should now offer this credential the next time you open the app." + }, "filters": { "all": "Items", "showFolders": "Folders",