mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-09 07:46:13 -04:00
Add Android autofill link to existing credential flow (#1825)
This commit is contained in:
committed by
Leendert de Borst
parent
f0c8f96a1c
commit
c4fdb194ce
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
384
apps/mobile-app/app/(tabs)/items/autofill-link-existing.tsx
Normal file
384
apps/mobile-app/app/(tabs)/items/autofill-link-existing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
apps/mobile-app/app/(tabs)/items/autofill-open-app.tsx
Normal file
225
apps/mobile-app/app/(tabs)/items/autofill-open-app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
apps/mobile-app/app/(tabs)/items/autofill-url-added.tsx
Normal file
153
apps/mobile-app/app/(tabs)/items/autofill-url-added.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user