diff --git a/apps/mobile-app/app/(tabs)/items/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx index 2cafabdb5..ee27e4906 100644 --- a/apps/mobile-app/app/(tabs)/items/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -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 => { 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 }} > diff --git a/apps/mobile-app/app/(tabs)/settings/clipboard-clear.tsx b/apps/mobile-app/app/(tabs)/settings/clipboard-clear.tsx index 22e3d5d57..5461d02d0 100644 --- a/apps/mobile-app/app/(tabs)/settings/clipboard-clear.tsx +++ b/apps/mobile-app/app/(tabs)/settings/clipboard-clear.tsx @@ -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(10); const [isIgnoringBatteryOptimizations, setIsIgnoringBatteryOptimizations] = useState(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 => { - 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 => { - await setClipboardClearTimeout(timeout); + await LocalPreferencesService.setClipboardClearTimeout(timeout); setSelectedTimeout(timeout); }; diff --git a/apps/mobile-app/app/(tabs)/settings/index.tsx b/apps/mobile-app/app/(tabs)/settings/index.tsx index 37e423b6c..7ae5765ef 100644 --- a/apps/mobile-app/app/(tabs)/settings/index.tsx +++ b/apps/mobile-app/app/(tabs)/settings/index.tsx @@ -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 => { - 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]) ); /** diff --git a/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx b/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx index 9851c3a7d..f08fcd855 100644 --- a/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx +++ b/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx @@ -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 = ({ 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 = ({ 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 = ({ } 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 = ({ 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); diff --git a/apps/mobile-app/components/items/FieldHistoryModal.tsx b/apps/mobile-app/components/items/FieldHistoryModal.tsx index 9e744dec1..4d02ce60d 100644 --- a/apps/mobile-app/components/items/FieldHistoryModal.tsx +++ b/apps/mobile-app/components/items/FieldHistoryModal.tsx @@ -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 = ({ const { t } = useTranslation(); const colors = useColors(); const dbContext = useDb(); - const { getClipboardClearTimeout } = useAuth(); const { executeVaultMutation } = useVaultMutate(); const { showConfirm } = useDialog(); const [history, setHistory] = useState([]); @@ -276,7 +275,7 @@ const FieldHistoryModal: React.FC = ({ const handleCopy = async (): Promise => { try { - const timeoutSeconds = await getClipboardClearTimeout(); + const timeoutSeconds = await LocalPreferencesService.getClipboardClearTimeout(); await copyToClipboardWithExpiration(value, timeoutSeconds); Toast.show({ type: 'success', diff --git a/apps/mobile-app/components/items/ItemCard.tsx b/apps/mobile-app/components/items/ItemCard.tsx index 405ba6344..c9c1cea37 100644 --- a/apps/mobile-app/components/items/ItemCard.tsx +++ b/apps/mobile-app/components/items/ItemCard.tsx @@ -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 => { try { // Get clipboard clear timeout from settings - const timeoutSeconds = await getClipboardClearTimeout(); + const timeoutSeconds = await LocalPreferencesService.getClipboardClearTimeout(); // Use centralized clipboard utility await copyToClipboardWithExpiration(text, timeoutSeconds); diff --git a/apps/mobile-app/components/items/details/TotpSection.tsx b/apps/mobile-app/components/items/details/TotpSection.tsx index 723928b78..406dd8eb0 100644 --- a/apps/mobile-app/components/items/details/TotpSection.tsx +++ b/apps/mobile-app/components/items/details/TotpSection.tsx @@ -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 = ({ 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 = ({ item }) : React.ReactN const copyToClipboardWithClear = async (code: string): Promise => { try { // Get clipboard clear timeout from settings - const timeoutSeconds = await getClipboardClearTimeout(); + const timeoutSeconds = await LocalPreferencesService.getClipboardClearTimeout(); // Use centralized clipboard utility await copyToClipboardWithExpiration(code, timeoutSeconds); diff --git a/apps/mobile-app/context/AppContext.tsx b/apps/mobile-app/context/AppContext.tsx index 000fcbd81..b44ee4f7f 100644 --- a/apps/mobile-app/context/AppContext.tsx +++ b/apps/mobile-app/context/AppContext.tsx @@ -24,8 +24,6 @@ type AppContextType = { getAuthMethodDisplayKey: () => Promise; getAutoLockTimeout: () => Promise; setAutoLockTimeout: (timeout: number) => Promise; - getClipboardClearTimeout: () => Promise; - setClipboardClearTimeout: (timeout: number) => Promise; getBiometricDisplayName: () => Promise; isBiometricsEnabledOnDevice: () => Promise; 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, diff --git a/apps/mobile-app/context/AuthContext.tsx b/apps/mobile-app/context/AuthContext.tsx index 5669f3342..37789e0c6 100644 --- a/apps/mobile-app/context/AuthContext.tsx +++ b/apps/mobile-app/context/AuthContext.tsx @@ -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>(); @@ -41,8 +42,6 @@ type AuthContextType = { getAuthMethodDisplayKey: () => Promise; getAutoLockTimeout: () => Promise; setAutoLockTimeout: (timeout: number) => Promise; - getClipboardClearTimeout: () => Promise; - setClipboardClearTimeout: (timeout: number) => Promise; getBiometricDisplayName: () => Promise; isBiometricsEnabledOnDevice: () => Promise; setOfflineMode: (isOffline: boolean) => void; @@ -53,8 +52,6 @@ type AuthContextType = { markAutofillConfigured: () => Promise; } -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 => { - 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 => { - 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, diff --git a/apps/mobile-app/services/LocalPreferencesService.ts b/apps/mobile-app/services/LocalPreferencesService.ts new file mode 100644 index 000000000..12bf2cd87 --- /dev/null +++ b/apps/mobile-app/services/LocalPreferencesService.ts @@ -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 { + const value = await AsyncStorage.getItem(KEYS.AUTOFILL_CONFIGURED); + return value === 'true'; + }, + + /** + * Set whether autofill has been configured. + */ + async setAutofillConfigured(configured: boolean): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await Promise.all([ + AsyncStorage.removeItem(KEYS.AUTOFILL_CONFIGURED), + AsyncStorage.removeItem(KEYS.CLIPBOARD_CLEAR_TIMEOUT), + AsyncStorage.removeItem(KEYS.SHOW_FOLDERS), + ]); + }, +};