Refactor mobile app AsyncStorage usages to shared LocalPreferencesService (#1598)

This commit is contained in:
Leendert de Borst
2026-02-03 11:36:39 +01:00
committed by Leendert de Borst
parent 7d865d5155
commit 38ce264cd9
10 changed files with 122 additions and 76 deletions

View File

@@ -1,5 +1,4 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
import { useRouter, useLocalSearchParams } from 'expo-router';
@@ -36,11 +35,7 @@ import { RobustPressable } from '@/components/ui/RobustPressable';
import { SkeletonLoader } from '@/components/ui/SkeletonLoader';
import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
/**
* Storage key for the show folders preference.
*/
const SHOW_FOLDERS_STORAGE_KEY = 'items-show-folders';
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
/**
* Filter types for the items list.
@@ -129,17 +124,15 @@ export default function ItemsScreen(): React.ReactNode {
}
}, [itemUrl]);
// Load saved showFolderItems preference from AsyncStorage
// Load saved showFolderItems preference from LocalPreferencesService
useEffect(() => {
/**
* Load the show folders preference from AsyncStorage.
* Load the show folders preference from LocalPreferencesService.
*/
const loadShowFoldersPreference = async (): Promise<void> => {
try {
const stored = await AsyncStorage.getItem(SHOW_FOLDERS_STORAGE_KEY);
if (stored !== null) {
setShowFolderItems(stored === 'true');
}
const stored = await LocalPreferencesService.getShowFolders();
setShowFolderItems(stored);
} catch {
// Ignore storage errors, use default value
}
@@ -826,7 +819,7 @@ export default function ItemsScreen(): React.ReactNode {
onPress={() => {
const newValue = !showFolderItems;
setShowFolderItems(newValue);
AsyncStorage.setItem(SHOW_FOLDERS_STORAGE_KEY, String(newValue));
LocalPreferencesService.setShowFolders(newValue);
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>

View File

@@ -8,7 +8,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
import { useAuth } from '@/context/AuthContext';
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
import NativeVaultManager from '@/specs/NativeVaultManager';
/**
@@ -25,7 +25,6 @@ export default function ClipboardClearScreen(): React.ReactNode {
{ value: 15, label: t('settings.clipboardClearOptions.15seconds') },
{ value: 30, label: t('settings.clipboardClearOptions.30seconds') },
];
const { getClipboardClearTimeout, setClipboardClearTimeout } = useAuth();
const [selectedTimeout, setSelectedTimeout] = useState<number>(10);
const [isIgnoringBatteryOptimizations, setIsIgnoringBatteryOptimizations] = useState<boolean>(true);
const appState = useRef(AppState.currentState);
@@ -35,7 +34,7 @@ export default function ClipboardClearScreen(): React.ReactNode {
* Load the current clipboard clear timeout.
*/
const loadCurrentTimeout = async (): Promise<void> => {
const timeout = await getClipboardClearTimeout();
const timeout = await LocalPreferencesService.getClipboardClearTimeout();
setSelectedTimeout(timeout);
};
@@ -72,13 +71,13 @@ export default function ClipboardClearScreen(): React.ReactNode {
return (): void => {
subscription.remove();
};
}, [getClipboardClearTimeout]);
}, []);
/**
* Handle timeout change.
*/
const handleTimeoutChange = async (timeout: number): Promise<void> => {
await setClipboardClearTimeout(timeout);
await LocalPreferencesService.setClipboardClearTimeout(timeout);
setSelectedTimeout(timeout);
};

View File

@@ -20,6 +20,7 @@ import { TitleContainer } from '@/components/ui/TitleContainer';
import { UsernameDisplay } from '@/components/ui/UsernameDisplay';
import { useApp } from '@/context/AppContext';
import { useDialog } from '@/context/DialogContext';
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
/**
* Settings screen.
@@ -30,7 +31,7 @@ export default function SettingsScreen() : React.ReactNode {
const { showAlert, showConfirm } = useDialog();
const insets = useSafeAreaInsets();
const { getAuthMethodDisplayKey, shouldShowAutofillReminder } = useApp();
const { getAutoLockTimeout, getClipboardClearTimeout } = useApp();
const { getAutoLockTimeout } = useApp();
const { logoutUserInitiated } = useLogout();
const { loadApiUrl, getDisplayUrl } = useApiUrl();
const scrollY = useRef(new Animated.Value(0)).current;
@@ -74,7 +75,7 @@ export default function SettingsScreen() : React.ReactNode {
* Load the clipboard clear display.
*/
const loadClipboardClearDisplay = async () : Promise<void> => {
const clipboardTimeout = await getClipboardClearTimeout();
const clipboardTimeout = await LocalPreferencesService.getClipboardClearTimeout();
let display = t('common.never');
if (clipboardTimeout === 5) {
@@ -107,7 +108,7 @@ export default function SettingsScreen() : React.ReactNode {
};
loadData();
}, [getAutoLockTimeout, getAuthMethodDisplayKey, setIsFirstLoad, loadApiUrl, getClipboardClearTimeout, t])
}, [getAutoLockTimeout, getAuthMethodDisplayKey, setIsFirstLoad, loadApiUrl, t])
);
/**

View File

@@ -8,8 +8,8 @@ import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility';
import { useColors } from '@/hooks/useColorScheme';
import { useAuth } from '@/context/AuthContext';
import { useClipboardCountdown } from '@/context/ClipboardCountdownContext';
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
type FormInputCopyToClipboardProps = {
label: string;
@@ -31,7 +31,6 @@ const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const colors = useColors();
const { t } = useTranslation();
const { getClipboardClearTimeout } = useAuth();
const { activeFieldId, setActiveField } = useClipboardCountdown();
const animatedWidth = useRef(new Animated.Value(0)).current;
@@ -57,7 +56,7 @@ const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
animatedWidth.setValue(100);
// Get timeout and start animation
getClipboardClearTimeout().then((timeoutSeconds) => {
LocalPreferencesService.getClipboardClearTimeout().then((timeoutSeconds) => {
if (!isCancelled && timeoutSeconds > 0 && activeFieldId === fieldId) {
animationRef = Animated.timing(animatedWidth, {
toValue: 0,
@@ -92,7 +91,7 @@ const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
}
animatedWidth.stopAnimation();
};
}, [isCountingDown, activeFieldId, fieldId, animatedWidth, setActiveField, getClipboardClearTimeout]);
}, [isCountingDown, activeFieldId, fieldId, animatedWidth, setActiveField]);
/**
* Copy the value to the clipboard.
@@ -101,7 +100,7 @@ const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
if (value) {
try {
// Get clipboard clear timeout from settings
const timeoutSeconds = await getClipboardClearTimeout();
const timeoutSeconds = await LocalPreferencesService.getClipboardClearTimeout();
// Use centralized clipboard utility
await copyToClipboardWithExpiration(value, timeoutSeconds);

View File

@@ -16,8 +16,8 @@ import { FieldTypes } from '@/utils/dist/core/models/vault';
import { useColors } from '@/hooks/useColorScheme';
import { useDb } from '@/context/DbContext';
import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility';
import { useAuth } from '@/context/AuthContext';
import { ModalWrapper } from '@/components/common/ModalWrapper';
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { useDialog } from '@/context/DialogContext';
@@ -49,7 +49,6 @@ const FieldHistoryModal: React.FC<FieldHistoryModalProps> = ({
const { t } = useTranslation();
const colors = useColors();
const dbContext = useDb();
const { getClipboardClearTimeout } = useAuth();
const { executeVaultMutation } = useVaultMutate();
const { showConfirm } = useDialog();
const [history, setHistory] = useState<FieldHistory[]>([]);
@@ -276,7 +275,7 @@ const FieldHistoryModal: React.FC<FieldHistoryModalProps> = ({
const handleCopy = async (): Promise<void> => {
try {
const timeoutSeconds = await getClipboardClearTimeout();
const timeoutSeconds = await LocalPreferencesService.getClipboardClearTimeout();
await copyToClipboardWithExpiration(value, timeoutSeconds);
Toast.show({
type: 'success',

View File

@@ -9,8 +9,8 @@ import type { NativeSyntheticEvent } from 'react-native';
import Toast from 'react-native-toast-message';
import { ItemIcon } from '@/components/items/ItemIcon';
import { useAuth } from '@/context/AuthContext';
import { useDialog } from '@/context/DialogContext';
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
import { useColors } from '@/hooks/useColorScheme';
import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility';
import type { Item } from '@/utils/dist/core/models/vault';
@@ -27,7 +27,6 @@ type ItemCardProps = {
export function ItemCard({ item, onItemDelete }: ItemCardProps): React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const { getClipboardClearTimeout } = useAuth();
const { showConfirm } = useDialog();
/**
@@ -68,7 +67,7 @@ export function ItemCard({ item, onItemDelete }: ItemCardProps): React.ReactNode
const copyToClipboard = async (text: string): Promise<void> => {
try {
// Get clipboard clear timeout from settings
const timeoutSeconds = await getClipboardClearTimeout();
const timeoutSeconds = await LocalPreferencesService.getClipboardClearTimeout();
// Use centralized clipboard utility
await copyToClipboardWithExpiration(text, timeoutSeconds);

View File

@@ -11,8 +11,8 @@ import { useColors } from '@/hooks/useColorScheme';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
type TotpSectionProps = {
item: Item;
@@ -27,7 +27,6 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ item }) : React.ReactN
const colors = useColors();
const dbContext = useDb();
const { t } = useTranslation();
const { getClipboardClearTimeout } = useAuth();
/**
* Get the remaining seconds.
@@ -74,7 +73,7 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ item }) : React.ReactN
const copyToClipboardWithClear = async (code: string): Promise<void> => {
try {
// Get clipboard clear timeout from settings
const timeoutSeconds = await getClipboardClearTimeout();
const timeoutSeconds = await LocalPreferencesService.getClipboardClearTimeout();
// Use centralized clipboard utility
await copyToClipboardWithExpiration(code, timeoutSeconds);

View File

@@ -24,8 +24,6 @@ type AppContextType = {
getAuthMethodDisplayKey: () => Promise<string>;
getAutoLockTimeout: () => Promise<number>;
setAutoLockTimeout: (timeout: number) => Promise<void>;
getClipboardClearTimeout: () => Promise<number>;
setClipboardClearTimeout: (timeout: number) => Promise<void>;
getBiometricDisplayName: () => Promise<string>;
isBiometricsEnabledOnDevice: () => Promise<boolean>;
setOfflineMode: (isOffline: boolean) => void;
@@ -116,8 +114,6 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
getAuthMethodDisplayKey: auth.getAuthMethodDisplayKey,
getAutoLockTimeout: auth.getAutoLockTimeout,
setAutoLockTimeout: auth.setAutoLockTimeout,
getClipboardClearTimeout: auth.getClipboardClearTimeout,
setClipboardClearTimeout: auth.setClipboardClearTimeout,
getBiometricDisplayName: auth.getBiometricDisplayName,
isBiometricsEnabledOnDevice: auth.isBiometricsEnabledOnDevice,
setOfflineMode: auth.setOfflineMode,
@@ -139,8 +135,6 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
auth.getAuthMethodDisplayKey,
auth.getAutoLockTimeout,
auth.setAutoLockTimeout,
auth.getClipboardClearTimeout,
auth.setClipboardClearTimeout,
auth.getBiometricDisplayName,
auth.isBiometricsEnabledOnDevice,
auth.setOfflineMode,

View File

@@ -11,6 +11,7 @@ import { useDb } from '@/context/DbContext';
import { dialogEventEmitter } from '@/events/DialogEventEmitter';
import NativeVaultManager from '@/specs/NativeVaultManager';
import i18n from '@/i18n';
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
// Create a navigation reference
export const navigationRef = React.createRef<NavigationContainerRef<ParamListBase>>();
@@ -41,8 +42,6 @@ type AuthContextType = {
getAuthMethodDisplayKey: () => Promise<string>;
getAutoLockTimeout: () => Promise<number>;
setAutoLockTimeout: (timeout: number) => Promise<void>;
getClipboardClearTimeout: () => Promise<number>;
setClipboardClearTimeout: (timeout: number) => Promise<void>;
getBiometricDisplayName: () => Promise<string>;
isBiometricsEnabledOnDevice: () => Promise<boolean>;
setOfflineMode: (isOffline: boolean) => void;
@@ -53,8 +52,6 @@ type AuthContextType = {
markAutofillConfigured: () => Promise<void>;
}
const AUTOFILL_CONFIGURED_KEY = 'autofill_configured';
const CLIPBOARD_TIMEOUT_KEY = 'clipboard_clear_timeout';
/**
* Auth context.
@@ -351,30 +348,6 @@ export const AuthProvider: React.FC<{
}
}, []);
/**
* Get the clipboard clear timeout from AsyncStorage
*/
const getClipboardClearTimeout = useCallback(async (): Promise<number> => {
try {
const timeoutStr = await AsyncStorage.getItem(CLIPBOARD_TIMEOUT_KEY);
return timeoutStr ? parseInt(timeoutStr, 10) : 15;
} catch (error) {
console.error('Failed to get clipboard clear timeout:', error);
return 10;
}
}, []);
/**
* Set the clipboard clear timeout in AsyncStorage
*/
const setClipboardClearTimeout = useCallback(async (timeout: number): Promise<void> => {
try {
await AsyncStorage.setItem(CLIPBOARD_TIMEOUT_KEY, timeout.toString());
} catch (error) {
console.error('Failed to set clipboard clear timeout:', error);
}
}, []);
/**
* Get the encryption key derivation parameters from native storage.
* Returns parsed parameters or null if not available.
@@ -429,15 +402,15 @@ export const AuthProvider: React.FC<{
* Load autofill state from storage
*/
const loadAutofillState = useCallback(async () => {
const configured = await AsyncStorage.getItem(AUTOFILL_CONFIGURED_KEY);
setShouldShowAutofillReminder(configured !== 'true');
const configured = await LocalPreferencesService.getAutofillConfigured();
setShouldShowAutofillReminder(!configured);
}, []);
/**
* Mark autofill as configured for the current platform
*/
const markAutofillConfigured = useCallback(async () => {
await AsyncStorage.setItem(AUTOFILL_CONFIGURED_KEY, 'true');
await LocalPreferencesService.setAutofillConfigured(true);
setShouldShowAutofillReminder(false);
}, []);
@@ -472,8 +445,6 @@ export const AuthProvider: React.FC<{
isBiometricsEnabledOnDevice,
getAutoLockTimeout,
setAutoLockTimeout,
getClipboardClearTimeout,
setClipboardClearTimeout,
getBiometricDisplayName,
markAutofillConfigured,
verifyPassword,
@@ -497,8 +468,6 @@ export const AuthProvider: React.FC<{
isBiometricsEnabledOnDevice,
getAutoLockTimeout,
setAutoLockTimeout,
getClipboardClearTimeout,
setClipboardClearTimeout,
getBiometricDisplayName,
markAutofillConfigured,
verifyPassword,

View File

@@ -0,0 +1,94 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* Storage keys for local preferences.
* These are defined inline since they're only used by this service.
*/
const KEYS = {
// Autofill configuration
AUTOFILL_CONFIGURED: 'autofill_configured',
// Timeouts
CLIPBOARD_CLEAR_TIMEOUT: 'clipboard_clear_timeout',
// UI preferences
SHOW_FOLDERS: 'items-show-folders',
} as const;
/**
* Service for managing user preferences that are stored locally (not in the vault).
* Provides typed getters/setters with sensible defaults for all local storage settings.
*
* Note: This service handles UI preferences stored in AsyncStorage.
* Security-sensitive settings (auth tokens, vault data) are handled by the native layer.
*/
export const LocalPreferencesService = {
/**
* Get whether autofill has been configured by the user.
* @returns Whether autofill has been configured. Defaults to false.
*/
async getAutofillConfigured(): Promise<boolean> {
const value = await AsyncStorage.getItem(KEYS.AUTOFILL_CONFIGURED);
return value === 'true';
},
/**
* Set whether autofill has been configured.
*/
async setAutofillConfigured(configured: boolean): Promise<void> {
await AsyncStorage.setItem(KEYS.AUTOFILL_CONFIGURED, configured.toString());
},
/**
* Get the clipboard clear timeout in seconds.
* @returns Timeout in seconds. Defaults to 15.
*/
async getClipboardClearTimeout(): Promise<number> {
const value = await AsyncStorage.getItem(KEYS.CLIPBOARD_CLEAR_TIMEOUT);
return value ? parseInt(value, 10) : 15;
},
/**
* Set the clipboard clear timeout in seconds.
*/
async setClipboardClearTimeout(timeout: number): Promise<void> {
await AsyncStorage.setItem(KEYS.CLIPBOARD_CLEAR_TIMEOUT, timeout.toString());
},
/**
* Get the show folders preference.
* @returns Whether to show folders (true) or show all items flat (false). Defaults to true.
*/
async getShowFolders(): Promise<boolean> {
const value = await AsyncStorage.getItem(KEYS.SHOW_FOLDERS);
// Default to true if not set
return value === null ? true : value === 'true';
},
/**
* Set the show folders preference.
*/
async setShowFolders(showFolders: boolean): Promise<void> {
await AsyncStorage.setItem(KEYS.SHOW_FOLDERS, showFolders.toString());
},
/**
* Clear all UI preferences. Can be called on logout.
* Note: This only clears UI preferences, not security-related settings.
*/
async clearUiPreferences(): Promise<void> {
await AsyncStorage.removeItem(KEYS.SHOW_FOLDERS);
},
/**
* Clear all preferences. Called on logout to reset everything.
* Note: Security settings are handled by the native layer.
*/
async clearAll(): Promise<void> {
await Promise.all([
AsyncStorage.removeItem(KEYS.AUTOFILL_CONFIGURED),
AsyncStorage.removeItem(KEYS.CLIPBOARD_CLEAR_TIMEOUT),
AsyncStorage.removeItem(KEYS.SHOW_FOLDERS),
]);
},
};