Add Android autofill link to existing credential flow (#1825)

This commit is contained in:
Leendert de Borst
2026-04-29 22:20:11 +02:00
committed by Leendert de Borst
parent f0c8f96a1c
commit c4fdb194ce
8 changed files with 812 additions and 7 deletions

View File

@@ -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 {

View File

@@ -11,7 +11,7 @@
<string name="common_back">Back</string>
<string name="unknown_error">An unknown error occurred</string>
<string name="autofill_failed_to_retrieve">Failed to retrieve, open app</string>
<string name="autofill_no_match_found">No match found, create new?</string>
<string name="autofill_no_match_found">No match found</string>
<string name="autofill_open_app">Open app</string>
<string name="autofill_vault_locked">Vault locked</string>
<string name="biometric_store_key_title">Store Encryption Key</string>

View File

@@ -52,6 +52,28 @@ export default function ItemsLayout(): React.ReactNode {
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="autofill-open-app"
options={{
title: t('items.autofillOpenApp.title'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="autofill-link-existing"
options={{
title: t('items.autofillLinkExisting.title'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="autofill-url-added"
options={{
title: t('items.autofillUrlAdded.title'),
presentation: Platform.OS === 'ios' ? 'modal' : 'card',
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="[id]"
options={{

View File

@@ -110,7 +110,7 @@ export default function AutofillCredentialCreatedScreen() : React.ReactNode {
{t('items.itemCreatedMessage')}
</ThemedText>
<ThemedText style={[styles.message, styles.boldMessage]}>
{t('items.switchBackToBrowser')}
{t('items.switchBackToOriginalApp')}
</ThemedText>
</ThemedView>
</ThemedSafeAreaView>

View File

@@ -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<Item[]>([]);
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<void> => {
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<void> => {
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<Item> = useCallback((info) => {
const row = info.item;
const username = getFieldValue(row, FieldKey.LoginUsername);
const email = getFieldValue(row, FieldKey.LoginEmail);
const detail = username || email || '';
return (
<RobustPressable
style={styles.itemRow}
onPress={() => handleSelectItem(row)}
testID={`autofill-link-item-${row.Id}`}
>
<ItemIcon item={row} />
<View style={styles.itemTextWrapper}>
<ThemedText style={styles.itemName} numberOfLines={1}>
{row.Name || t('items.untitled')}
</ThemedText>
{detail.length > 0 && (
<ThemedText style={styles.itemDetail} numberOfLines={1}>
{detail}
</ThemedText>
)}
</View>
<MaterialIcons name="add-link" size={22} color={colors.primary} />
</RobustPressable>
);
}, [colors.primary, handleSelectItem, styles, t]);
return (
<ThemedSafeAreaView style={styles.container}>
<ThemedView style={styles.headerArea}>
<ThemedText style={styles.introText}>
{t('items.autofillLinkExisting.intro', { target: decodedAppInfo })}
</ThemedText>
<View style={styles.searchContainer}>
<MaterialIcons
name="search"
size={20}
color={colors.textMuted}
style={styles.searchIcon}
/>
<TextInput
style={styles.searchInput}
placeholder={t('items.searchPlaceholder')}
placeholderTextColor={colors.textMuted}
value={searchQuery}
autoCorrect={false}
autoCapitalize="none"
onChangeText={setSearchQuery}
clearButtonMode={Platform.OS === 'ios' ? 'while-editing' : 'never'}
testID="autofill-link-search"
/>
{Platform.OS === 'android' && searchQuery.length > 0 && (
<TouchableOpacity
style={styles.searchClearButton}
onPress={() => setSearchQuery('')}
testID="autofill-link-clear-search"
>
<ThemedText style={styles.searchClearText}>×</ThemedText>
</TouchableOpacity>
)}
</View>
</ThemedView>
<FlatList
data={filteredItems}
keyExtractor={item => item.Id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={(
<View style={styles.emptyState}>
<ThemedText style={styles.emptyText}>
{searchQuery
? t('items.noMatchingItemsSearch', { search: searchQuery })
: t('items.noItemsFound')}
</ThemedText>
</View>
)}
/>
</ThemedSafeAreaView>
);
}

View File

@@ -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 (
<ThemedSafeAreaView style={styles.container}>
<ThemedView style={styles.content}>
<ThemedText style={styles.introText}>
{t('items.autofillOpenApp.description')}
</ThemedText>
{decodedAppInfo.length > 0 && (
<View style={styles.appInfoBox}>
<ThemedText style={styles.appInfoLabel}>
{t('items.autofillOpenApp.appOrUrlLabel')}
</ThemedText>
<ThemedText style={styles.appInfoValue} numberOfLines={2}>
{decodedAppInfo}
</ThemedText>
</View>
)}
<RobustPressable
style={styles.optionCard}
onPress={handleFindExisting}
testID="autofill-find-existing-button"
>
<View style={styles.actionRow}>
<View style={styles.optionPrimaryIconWrapper}>
<MaterialIcons name="link" size={26} color={colors.primary} />
</View>
<View style={styles.actionTextWrapper}>
<ThemedText style={styles.actionTitle}>
{t('items.autofillOpenApp.findExistingTitle')}
</ThemedText>
<ThemedText style={styles.actionDescription}>
{t('items.autofillOpenApp.findExistingDescription')}
</ThemedText>
</View>
<MaterialIcons name="chevron-right" size={24} color={colors.textMuted} />
</View>
</RobustPressable>
<RobustPressable
style={styles.optionCard}
onPress={handleCreateNew}
testID="autofill-create-new-button"
>
<View style={styles.actionRow}>
<View style={styles.optionIconWrapper}>
<MaterialIcons name="add" size={26} color={colors.text} />
</View>
<View style={styles.actionTextWrapper}>
<ThemedText style={styles.actionTitle}>
{t('items.autofillOpenApp.createNewTitle')}
</ThemedText>
<ThemedText style={styles.actionDescription}>
{t('items.autofillOpenApp.createNewDescription')}
</ThemedText>
</View>
<MaterialIcons name="chevron-right" size={24} color={colors.textMuted} />
</View>
</RobustPressable>
</ThemedView>
</ThemedSafeAreaView>
);
}

View File

@@ -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 (
<ThemedSafeAreaView style={styles.container}>
<ThemedView style={styles.content}>
<View style={styles.iconContainer}>
<MaterialIcons name="task-alt" size={80} color={colors.primary} />
</View>
<ThemedText style={styles.title}>{t('items.autofillUrlAdded.title')}</ThemedText>
<ThemedText style={styles.message}>
{t('items.autofillUrlAdded.message', { name: decodedItemName })}
</ThemedText>
{decodedItemUrl.length > 0 && (
<View style={styles.detailBox}>
<ThemedText style={styles.detailLabel}>
{t('items.autofillOpenApp.appOrUrlLabel')}
</ThemedText>
<ThemedText style={styles.detailValue} numberOfLines={2}>
{decodedItemUrl}
</ThemedText>
</View>
)}
<ThemedText style={[styles.message, styles.boldMessage]}>
{t('items.switchBackToOriginalApp')}
</ThemedText>
</ThemedView>
</ThemedSafeAreaView>
);
}

View File

@@ -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",