diff --git a/mobile/.gitignore b/mobile/.gitignore index eee5694..4cb7853 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -40,3 +40,6 @@ yarn-error.* # generated native folders /ios /android + +modules/*/android/build/ +modules/*/android/.gradle/ \ No newline at end of file diff --git a/mobile/App.tsx b/mobile/App.tsx index b45bebc..f60ce8d 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -17,6 +17,8 @@ import { PendingReviewScreen } from './app/pending-review'; import { FileDetailScreen } from './app/file-detail'; import { FileEditScreen } from './app/file-edit'; import { FolderScreen } from './app/folder'; +import { OnboardingScreen } from './app/onboarding'; +import { onboardingStorage } from './services/onboardingStorage'; const Stack = createNativeStackNavigator(); const queryClient = new QueryClient(); @@ -32,11 +34,18 @@ function AppNavigator() { ); } + const needsOnboarding = user && onboardingStorage.needsOnboarding(); + return ( - + {user ? ( <> + }; }; type FileDetailRouteProp = RouteProp; -function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (state: ModalState) => void }) { - const { data: imageData, isLoading: imageLoading } = useFileImage(fileId); - const { data: fileData } = useFile(fileId); +function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; deviceFile?: DeviceFileParam; onSelectImage?: (state: ModalState) => void }) { + const isDevice = !!deviceFile; + const { data: imageData, isLoading: imageLoading } = useFileImage(isDevice ? '' : fileId); + const { data: fileData } = useFile(isDevice ? '' : fileId); const downloadFile = useDownloadFile(); const [downloading, setDownloading] = useState(false); - const uri = imageData?.data?.url; + const uri = isDevice ? deviceFile.localUri : (imageData?.data?.url); const file = fileData as any; const localEntry = localFileRegistry.getByBackendId(fileId); - const syncStatus: SyncStatus = localEntry - ? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced') - : (uri ? 'synced' : 'cloud'); + const syncStatus: SyncStatus = isDevice + ? 'local' + : localEntry + ? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced') + : (uri ? 'synced' : 'cloud'); const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? []) .filter((t: Thumbnail) => t.resolutionLabel === 'full') @@ -141,19 +151,19 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: - {imageData?.data?.name ?? file?.name ?? fileId} + {deviceFile?.name ?? imageData?.data?.name ?? file?.name ?? fileId} - {imageData?.data?.size != null && ( + {(deviceFile || imageData?.data?.size != null) && ( - Taille : {(imageData.data.size / 1024).toFixed(1)} Ko + Taille : {((deviceFile ? 0 : imageData?.data?.size) ?? 0 / 1024).toFixed(1)} Ko )} - {file?.createdAt && ( + {(deviceFile?.createdAt || file?.createdAt) && ( - Ajouté le {new Date(file.createdAt).toLocaleDateString('fr-FR', { + Ajouté le {new Date(deviceFile?.createdAt ?? file?.createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', })} @@ -183,7 +193,7 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: export function FileDetailScreen() { const route = useRoute(); - const { fileIds, initialIndex } = route.params; + const { fileIds, initialIndex, deviceFiles } = route.params; const flatListRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(initialIndex); @@ -223,7 +233,7 @@ export function FileDetailScreen() { }} renderItem={({ item }) => ( - + )} /> diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 77b7d9d..e30a5a9 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -10,6 +10,7 @@ import { UnifiedFileItem } from '../hooks/useUnifiedFiles'; import { FileItem, isFolder } from '../types'; import { SearchBar, SearchFilters } from '../components/SearchBar'; import { FileThumbnail } from '../components/FileThumbnail'; +import { safDirectory, StoredFolder } from '../services/safDirectory'; const NUM_COLUMNS = 3; const SCREEN_WIDTH = Dimensions.get('window').width; @@ -19,7 +20,7 @@ type RootStackParamList = { Home: undefined; Upload: undefined; Scan: undefined; - FileDetail: { fileIds: string[]; initialIndex: number }; + FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record }; FileEdit: { fileIds: string[] }; Folder: { folderId: string; folderName: string }; }; @@ -133,7 +134,7 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte export function HomeScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { data, isLoading, error, hasPermission, requestPermission } = useUnifiedFiles(); + const { data, isLoading, error, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useUnifiedFiles(); const deleteFile = useDeleteFile(); const freeLocalSpace = useFreeLocalSpace(); const [searchQuery, setSearchQuery] = useState(''); @@ -148,6 +149,7 @@ export function HomeScreen() { const moveFiles = useMoveFiles(); const { data: foldersData } = useFolders(); const [moveModalVisible, setMoveModalVisible] = useState(false); + const [folderSheetVisible, setFolderSheetVisible] = useState(false); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)); @@ -155,6 +157,16 @@ export function HomeScreen() { return () => { show.remove(); hide.remove(); }; }, []); + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + setFolderSheetVisible(true)} style={{ marginRight: 4, padding: 8 }}> + + + ), + }); + }, [navigation]); + const selectionMode = selectedIds.size > 0; const files = data ?? []; @@ -269,15 +281,50 @@ export function HomeScreen() { setSelectedIds(new Set()); }, [selectedIds, moveFiles]); + const handleToggleFolderVisibility = useCallback((folderId: string) => { + safDirectory.toggleVisibility(folderId); + refreshFolders(); + }, [refreshFolders]); + + const handleRemoveFolder = useCallback((folderId: string) => { + Alert.alert( + 'Supprimer le dossier', + 'Le dossier sera retiré de la liste. Les fichiers resteront sur votre appareil.', + [ + { text: 'Annuler', style: 'cancel' }, + { + text: 'Supprimer', + style: 'destructive', + onPress: () => { + safDirectory.removeFolder(folderId); + refreshFolders(); + }, + }, + ] + ); + }, [refreshFolders]); + + const handleAddFolder = useCallback(async () => { + setFolderSheetVisible(false); + await pickDirectory(); + }, [pickDirectory]); + const handleItemPress = useCallback((file: UnifiedFileItem) => { if (selectionMode) { toggleSelection(file.id); } else if (isFolder(file)) { navigation.navigate('Folder', { folderId: file.id, folderName: file.name }); } else { + const deviceFilesMap: Record = {}; + for (const f of filteredFiles) { + if (f.isDeviceFile && f.localUri) { + deviceFilesMap[f.id] = { localUri: f.localUri, name: f.name, mimeType: f.mimeType, createdAt: f.createdAt }; + } + } navigation.navigate('FileDetail', { fileIds: filteredFiles.map((f) => f.id), initialIndex: fileIdToIndex.get(file.id) ?? 0, + deviceFiles: Object.keys(deviceFilesMap).length > 0 ? deviceFilesMap : undefined, }); } }, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]); @@ -333,6 +380,14 @@ export function HomeScreen() { )} + {files.length === 0 && !searchQuery && ( + + + + {folders.length > 0 ? 'Aucun fichier trouvé — re-sélectionner' : 'Scanner un dossier de votre appareil'} + + + )} item} @@ -528,6 +583,45 @@ export function HomeScreen() { + + setFolderSheetVisible(false)}> + setFolderSheetVisible(false)}> + {}}> + + Mes dossiers + {folders.length === 0 ? ( + Aucun dossier configuré + ) : ( + folders.map((folder) => ( + + + {folder.name} + handleToggleFolderVisibility(folder.id)} + style={styles.folderRowAction} + > + + + handleRemoveFolder(folder.id)} + style={styles.folderRowAction} + > + + + + )) + )} + + + Ajouter un dossier + + + + ); } @@ -561,6 +655,19 @@ const styles = StyleSheet.create({ color: '#fff', fontWeight: '600', }, + folderScanBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFF3E0', + paddingHorizontal: 16, + paddingVertical: 14, + gap: 10, + }, + folderScanText: { + flex: 1, + fontSize: 14, + color: '#333', + }, center: { flex: 1, justifyContent: 'center', @@ -777,4 +884,69 @@ const styles = StyleSheet.create({ color: '#fff', fontWeight: '600', }, + emptyFoldersText: { + fontSize: 14, + color: '#999', + textAlign: 'center', + marginBottom: 16, + }, + folderRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + gap: 10, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + folderRowName: { + flex: 1, + fontSize: 15, + color: '#333', + }, + folderRowAction: { + padding: 6, + }, + addFolderBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#F57C00', + borderRadius: 10, + paddingVertical: 12, + marginTop: 16, + gap: 8, + }, + addFolderBtnText: { + fontSize: 15, + color: '#fff', + fontWeight: '600', + }, + bottomSheetOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + bottomSheet: { + backgroundColor: '#fff', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 32, + maxHeight: '70%', + }, + bottomSheetHandle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#ddd', + alignSelf: 'center', + marginBottom: 16, + }, + bottomSheetTitle: { + fontSize: 18, + fontWeight: '700', + color: '#333', + marginBottom: 16, + }, }); diff --git a/mobile/app/onboarding.tsx b/mobile/app/onboarding.tsx new file mode 100644 index 0000000..068ac7c --- /dev/null +++ b/mobile/app/onboarding.tsx @@ -0,0 +1,210 @@ +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Dimensions, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding'; +import { onboardingStorage } from '../services/onboardingStorage'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +const ICON_MAP: Record = { + 'waving-hand': 'waving-hand', + 'folder-off': 'folder-off', + 'create-new-folder': 'create-new-folder', +}; + +export function OnboardingScreen() { + const navigation = useNavigation(); + const [currentIndex, setCurrentIndex] = useState(0); + const pendingSteps = onboardingStorage.getPendingSteps(); + + const step = pendingSteps[currentIndex]; + + const complete = useCallback(() => { + onboardingStorage.setCompletedVersion(CURRENT_ONBOARDING_VERSION); + navigation.navigate('Home' as never); + }, [navigation]); + + const handlePickDirectory = useCallback(async () => { + const { safDirectory } = await import('../services/safDirectory'); + const FileSystem = await import('expo-file-system/legacy'); + + try { + const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync(); + if (!result.granted) return; + const dirUri = result.directoryUri; + const parts = dirUri.split('/'); + const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier'); + safDirectory.addFolder(dirUri, dirName); + } catch (err) { + console.error('[Onboarding] pickDirectory error:', err); + } + }, []); + + const handleNext = useCallback(async () => { + if (!step) return; + onboardingStorage.markStepSeen(step.id); + + if (currentIndex < pendingSteps.length - 1) { + setCurrentIndex(currentIndex + 1); + } else { + complete(); + } + }, [step, currentIndex, pendingSteps.length, complete]); + + const handleSkip = useCallback(() => { + complete(); + }, [complete]); + + if (!step) { + complete(); + return null; + } + + return ( + + + + Passer + + + + + + + + + {step.title} + {step.description} + + {step.action?.type === 'pick_directory' && ( + + + {step.action.label} + + )} + + + + + {pendingSteps.map((_, i) => ( + + ))} + + + + + {currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + skipContainer: { + alignItems: 'flex-end', + paddingHorizontal: 20, + paddingTop: 60, + }, + skipText: { + fontSize: 16, + color: '#999', + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + }, + iconContainer: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: '#E3F2FD', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 40, + }, + title: { + fontSize: 26, + fontWeight: '700', + color: '#333', + textAlign: 'center', + marginBottom: 16, + }, + description: { + fontSize: 16, + color: '#666', + textAlign: 'center', + lineHeight: 24, + }, + actionBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F57C00', + borderRadius: 12, + paddingHorizontal: 24, + paddingVertical: 14, + gap: 10, + marginTop: 32, + }, + actionBtnText: { + fontSize: 16, + color: '#fff', + fontWeight: '600', + }, + footer: { + paddingHorizontal: 40, + paddingBottom: 60, + alignItems: 'center', + gap: 24, + }, + dots: { + flexDirection: 'row', + gap: 8, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#ddd', + }, + dotActive: { + backgroundColor: '#1976D2', + width: 24, + }, + nextBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#1976D2', + borderRadius: 12, + paddingHorizontal: 32, + paddingVertical: 14, + gap: 8, + }, + nextBtnText: { + fontSize: 16, + color: '#fff', + fontWeight: '600', + }, +}); diff --git a/mobile/config/onboarding.ts b/mobile/config/onboarding.ts new file mode 100644 index 0000000..bddda85 --- /dev/null +++ b/mobile/config/onboarding.ts @@ -0,0 +1,42 @@ +export const CURRENT_ONBOARDING_VERSION = 1; + +export type OnboardingAction = { + type: 'pick_directory'; + label: string; +}; + +export type OnboardingStep = { + id: string; + version: number; + title: string; + description: string; + icon: string; + action?: OnboardingAction; + condition?: 'has_no_folders'; +}; + +export const ONBOARDING_STEPS: OnboardingStep[] = [ + { + id: 'welcome', + version: 1, + title: 'Bienvenue sur Dot.', + description: 'Votre espace document personnel, toujours accessible.', + icon: 'waving-hand', + }, + { + id: 'folder_explanation', + version: 1, + title: 'Pourquoi un dossier dédié ?', + description: 'Depuis Android 13, les applications ne peuvent plus accéder librement au dossier Téléchargements (par sécurité).\n\nDot. utilise un mécanisme spécial (SAF) pour lire vos fichiers. En créant un dossier dédié, vous autorisez l\'application à y accéder en toute sécurité.\n\n→ Déplacez vos documents dans ce dossier et ils apparaîtront automatiquement dans Dot.', + icon: 'folder-off', + }, + { + id: 'create_folder', + version: 1, + title: 'Créez votre dossier', + description: 'Sélectionnez ou créez un dossier dans le sélecteur ci-dessous.', + icon: 'create-new-folder', + action: { type: 'pick_directory', label: 'Sélectionner un dossier' }, + condition: 'has_no_folders', + }, +]; diff --git a/mobile/hooks/useDeviceFiles.ts b/mobile/hooks/useDeviceFiles.ts index 92c4a15..ae1199f 100644 --- a/mobile/hooks/useDeviceFiles.ts +++ b/mobile/hooks/useDeviceFiles.ts @@ -1,6 +1,11 @@ import { useState, useEffect, useCallback } from 'react'; import * as MediaLibrary from 'expo-media-library/legacy'; +import * as FileSystem from 'expo-file-system/legacy'; import { LocalFileEntry } from '../types'; +import { safDirectory, type StoredFolder } from '../services/safDirectory'; +import { downloadRegistry } from '../services/downloadRegistry'; +import { useFileWatcher } from './useFileWatcher'; +import { FileDetectedEvent } from '../modules/expo-download-detect'; export interface DeviceFile { id: string; @@ -9,29 +14,92 @@ export interface DeviceFile { mimeType: string; size: number; createdAt: string; + folderId?: string; +} + +function guessMimeType(name: string): string { + const ext = name.split('.').pop()?.toLowerCase() ?? ''; + const map: Record = { + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + txt: 'text/plain', + csv: 'text/csv', + json: 'application/json', + xml: 'application/xml', + zip: 'application/zip', + rar: 'application/x-rar-compressed', + mp4: 'video/mp4', + mp3: 'audio/mpeg', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + }; + return map[ext] ?? 'application/octet-stream'; +} + +function eventToDeviceFile(event: FileDetectedEvent): DeviceFile { + return { + id: event.id, + uri: event.uri, + name: event.name, + mimeType: event.mimeType, + size: event.size, + createdAt: new Date(event.createdAt).toISOString(), + }; +} + +async function scanSafFolder(folder: StoredFolder): Promise { + try { + const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(folder.uri); + const files: DeviceFile[] = []; + for (const entryUri of entries) { + const parts = entryUri.split('/'); + const name = decodeURIComponent(parts[parts.length - 1]); + if (name.startsWith('.')) continue; + files.push({ + id: `saf_${folder.id}_${entryUri}`, + uri: entryUri, + name, + mimeType: guessMimeType(name), + size: 0, + createdAt: new Date().toISOString(), + folderId: folder.id, + }); + } + return files; + } catch (err) { + console.error(`[useDeviceFiles] SAF scan error for folder "${folder.name}":`, err); + return []; + } } export function useDeviceFiles() { const [files, setFiles] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasPermission, setHasPermission] = useState(false); + const [folders, setFolders] = useState(() => safDirectory.getAll()); + const { newFiles, clearNewFiles } = useFileWatcher(); const requestPermission = useCallback(async () => { const req = await MediaLibrary.requestPermissionsAsync(); - console.log('[useDeviceFiles] requestPermission status:', req.status, 'granted:', req.granted, 'canAskAgain:', req.canAskAgain); if (req.granted) { setHasPermission(true); return true; } const check = await MediaLibrary.getPermissionsAsync(); - console.log('[useDeviceFiles] getPermissionsAsync status:', check.status, 'granted:', check.granted); const granted = check.granted; setHasPermission(granted); return granted; }, []); const loadAssets = useCallback(async () => { - console.log('[useDeviceFiles] loadAssets called'); setIsLoading(true); try { const result = await MediaLibrary.getAssetsAsync({ @@ -52,7 +120,6 @@ export function useDeviceFiles() { })); setFiles(deviceFiles); - console.log('[useDeviceFiles] loaded', deviceFiles.length, 'files'); } catch (err) { console.error('[useDeviceFiles] scan error:', err); setFiles([]); @@ -61,23 +128,120 @@ export function useDeviceFiles() { } }, []); - useEffect(() => { - requestPermission().then((granted) => { - console.log('[useDeviceFiles] mount permission result:', granted); - if (granted) { - loadAssets(); + const scanVisibleFolders = useCallback(async () => { + const visibleFolders = safDirectory.getVisibleFolders(); + if (visibleFolders.length === 0) return; + + const safFiles: DeviceFile[] = []; + for (const folder of visibleFolders) { + const folderFiles = await scanSafFolder(folder); + safFiles.push(...folderFiles); + } + + setFiles((prev) => { + const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id)); + const mediaOnly = prev.filter((f) => !f.folderId); + const merged = [...mediaOnly]; + for (const f of safFiles) { + if (!existing.has(f.id)) merged.push(f); } + return merged; }); }, []); + const loadRegistryFiles = useCallback(() => { + const registryEntries = downloadRegistry.getAll(); + const registryFiles: DeviceFile[] = registryEntries.map((entry) => ({ + id: entry.id, + uri: entry.uri, + name: entry.name, + mimeType: entry.mimeType, + size: entry.size, + createdAt: new Date(entry.createdAt).toISOString(), + })); + + setFiles((prev) => { + const existing = new Set(prev.map((f) => f.id)); + const merged = [...prev]; + for (const f of registryFiles) { + if (!existing.has(f.id)) merged.push(f); + } + return merged; + }); + }, []); + + const pickDirectory = useCallback(async () => { + try { + const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync(); + if (!result.granted) return false; + const dirUri = result.directoryUri; + const parts = dirUri.split('/'); + const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier'); + safDirectory.addFolder(dirUri, dirName); + setFolders(safDirectory.getAll()); + + const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true }); + setFiles((prev) => { + const existing = new Set(prev.map((f) => f.id)); + const merged = [...prev]; + for (const f of folderFiles) { + if (!existing.has(f.id)) merged.push(f); + } + return merged; + }); + return true; + } catch (err) { + console.error('[useDeviceFiles] pickDirectory error:', err); + return false; + } + }, []); + + const refreshFolders = useCallback(() => { + setFolders(safDirectory.getAll()); + }, []); + const rescan = useCallback(async () => { const granted = await requestPermission(); if (granted) { await loadAssets(); } - }, [requestPermission, loadAssets]); + await scanVisibleFolders(); + loadRegistryFiles(); + }, [requestPermission, loadAssets, scanVisibleFolders, loadRegistryFiles]); - return { files, isLoading, hasPermission, requestPermission, rescan }; + // Handle new files detected by native module + useEffect(() => { + if (newFiles.length === 0) return; + + // Persist to MMKV + downloadRegistry.addBatch(newFiles); + + // Add to in-memory state + const newDeviceFiles = newFiles.map(eventToDeviceFile); + setFiles((prev) => { + const existing = new Set(prev.map((f) => f.id)); + const merged = [...prev]; + for (const f of newDeviceFiles) { + if (!existing.has(f.id)) merged.push(f); + } + return merged; + }); + + clearNewFiles(); + }, [newFiles, clearNewFiles]); + + // Initial load + useEffect(() => { + requestPermission().then((granted) => { + if (granted) { + loadAssets(); + } + }); + scanVisibleFolders(); + loadRegistryFiles(); + }, []); + + return { files, isLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders }; } export function deviceFileToLocalEntry(deviceFile: DeviceFile): LocalFileEntry { diff --git a/mobile/hooks/useFileWatcher.ts b/mobile/hooks/useFileWatcher.ts new file mode 100644 index 0000000..479d33a --- /dev/null +++ b/mobile/hooks/useFileWatcher.ts @@ -0,0 +1,32 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Platform } from 'react-native'; +import { ExpoDownloadDetectModule, FileDetectedEvent } from '../modules/expo-download-detect'; + +export function useFileWatcher() { + const [newFiles, setNewFiles] = useState([]); + const [isSupported] = useState(() => Platform.OS === 'android'); + + const clearNewFiles = useCallback(() => { + setNewFiles([]); + }, []); + + useEffect(() => { + if (!isSupported) return; + + ExpoDownloadDetectModule.startWatching(); + + const subscription = ExpoDownloadDetectModule.addListener('onNewFile', (event: FileDetectedEvent) => { + setNewFiles((prev) => { + if (prev.some((f) => f.id === event.id)) return prev; + return [...prev, event]; + }); + }); + + return () => { + subscription.remove(); + ExpoDownloadDetectModule.stopWatching(); + }; + }, [isSupported]); + + return { newFiles, clearNewFiles, isSupported }; +} diff --git a/mobile/hooks/useLocalFiles.ts b/mobile/hooks/useLocalFiles.ts index 45dd867..8e8b345 100644 --- a/mobile/hooks/useLocalFiles.ts +++ b/mobile/hooks/useLocalFiles.ts @@ -4,7 +4,7 @@ import { localFileRegistry } from '../services/localFileRegistry'; import { LocalFileEntry } from '../types'; export function useLocalFiles() { - const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan } = useDeviceFiles(); + const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles(); const registryEntries = useMemo(() => localFileRegistry.getAll(), []); @@ -40,5 +40,8 @@ export function useLocalFiles() { hasPermission, requestPermission, rescan, + pickDirectory, + folders, + refreshFolders, }; } diff --git a/mobile/hooks/useUnifiedFiles.ts b/mobile/hooks/useUnifiedFiles.ts index 171dbcf..a5019de 100644 --- a/mobile/hooks/useUnifiedFiles.ts +++ b/mobile/hooks/useUnifiedFiles.ts @@ -43,7 +43,7 @@ function getExtension(name: string): string { export function useUnifiedFiles(page: number = 1, limit: number = 50) { const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit); - const { localFiles, isLoading: localLoading, hasPermission, requestPermission } = useLocalFiles(); + const { localFiles, isLoading: localLoading, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles(); const unifiedFiles = useMemo(() => { const backendFiles = backendData?.data ?? []; @@ -110,6 +110,9 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) { error: backendError, hasPermission, requestPermission, + pickDirectory, + folders, + refreshFolders, }; } diff --git a/mobile/modules/expo-download-detect/android/build.gradle b/mobile/modules/expo-download-detect/android/build.gradle new file mode 100644 index 0000000..9c2faae --- /dev/null +++ b/mobile/modules/expo-download-detect/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'host.exp.exponent' +version = '0.1.0' + +android { + namespace "expo.modules.downloaddetect" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } +} diff --git a/mobile/modules/expo-download-detect/android/src/main/AndroidManifest.xml b/mobile/modules/expo-download-detect/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c4e83de --- /dev/null +++ b/mobile/modules/expo-download-detect/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/DownloadBroadcastReceiver.kt b/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/DownloadBroadcastReceiver.kt new file mode 100644 index 0000000..1d372ba --- /dev/null +++ b/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/DownloadBroadcastReceiver.kt @@ -0,0 +1,77 @@ +package expo.modules.downloaddetect + +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +data class DetectedFile( + val id: String, + val uri: String, + val name: String, + val mimeType: String, + val size: Long, + val createdAt: Long +) + +class DownloadBroadcastReceiver( + private val onFileDetected: (DetectedFile) -> Unit +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return + + val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) + if (downloadId == -1L) return + + val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val query = DownloadManager.Query().setFilterById(downloadId) + + dm.query(query)?.use { cursor -> + if (!cursor.moveToFirst()) return + + val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) + val titleIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TITLE) + val mimeTypeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_MEDIA_TYPE) + val sizeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val dateIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP) + + val uri = if (uriIndex >= 0) cursor.getString(uriIndex) ?: "" else "" + val title = if (titleIndex >= 0) cursor.getString(titleIndex) ?: "unknown" else "unknown" + val mimeType = if (mimeTypeIndex >= 0) cursor.getString(mimeTypeIndex) ?: "application/octet-stream" else "application/octet-stream" + val size = if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L + val date = if (dateIndex >= 0) cursor.getLong(dateIndex) else System.currentTimeMillis() + + // Extract filename from URI + val name = extractFileName(uri, title) + + onFileDetected(DetectedFile( + id = "download_$downloadId", + uri = uri, + name = name, + mimeType = mimeType, + size = size, + createdAt = date + )) + } + } + + private fun extractFileName(uri: String, fallback: String): String { + if (uri.isNotEmpty()) { + // content://media/external/downloads/123 or file:///storage/... + val path = uri.substringAfterLast("/") + if (path.isNotEmpty() && path.all { it.isDigit() }.not()) { + return decodeFileName(path) + } + } + return fallback + } + + private fun decodeFileName(encoded: String): String { + return try { + java.net.URLDecoder.decode(encoded, "UTF-8") + } catch (_: Exception) { + encoded + } + } +} diff --git a/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/ExpoDownloadDetectModule.kt b/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/ExpoDownloadDetectModule.kt new file mode 100644 index 0000000..69250e5 --- /dev/null +++ b/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/ExpoDownloadDetectModule.kt @@ -0,0 +1,169 @@ +package expo.modules.downloaddetect + +import android.app.DownloadManager +import android.content.Context +import android.content.IntentFilter +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import androidx.core.os.bundleOf +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoDownloadDetectModule : Module() { + + private var broadcastReceiver: DownloadBroadcastReceiver? = null + private var contentObserver: MediaStoreObserver? = null + private var isWatching = false + + override fun definition() = ModuleDefinition { + Name("ExpoDownloadDetect") + + Events("onNewFile") + + Function("startWatching") { + if (!isWatching) startObserving() + null + } + + Function("stopWatching") { + stopObserving() + } + + AsyncFunction("getRecentDownloads") { + queryRecentFiles() + } + } + + private val context: Context + get() = requireNotNull(appContext.reactContext) + + private fun startObserving() { + val ctx = context + + // Register BroadcastReceiver for DownloadManager + broadcastReceiver = DownloadBroadcastReceiver { event -> + sendEvent("onNewFile", bundleOf( + "id" to event.id, + "uri" to event.uri, + "name" to event.name, + "mimeType" to event.mimeType, + "size" to event.size, + "createdAt" to event.createdAt, + "source" to "download" + )) + } + + val intentFilter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + ctx.registerReceiver(broadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED) + + // Register ContentObserver on MediaStore + val handler = Handler(Looper.getMainLooper()) + contentObserver = MediaStoreObserver(handler, ctx) { newFiles -> + for (file in newFiles) { + sendEvent("onNewFile", bundleOf( + "id" to file.id, + "uri" to file.uri, + "name" to file.name, + "mimeType" to file.mimeType, + "size" to file.size, + "createdAt" to file.createdAt, + "source" to "mediastore" + )) + } + } + + val uri = MediaStore.Files.getContentUri("external") + ctx.contentResolver.registerContentObserver(uri, true, contentObserver!!) + + // Scan existing files on startup + val existingFiles = queryRecentFiles() + for (file in existingFiles) { + sendEvent("onNewFile", bundleOf( + "id" to file.id, + "uri" to file.uri, + "name" to file.name, + "mimeType" to file.mimeType, + "size" to file.size, + "createdAt" to file.createdAt, + "source" to "startup" + )) + } + + isWatching = true + } + + private fun stopObserving() { + val ctx = context + + broadcastReceiver?.let { + try { + ctx.unregisterReceiver(it) + } catch (_: Exception) {} + broadcastReceiver = null + } + + contentObserver?.let { + ctx.contentResolver.unregisterContentObserver(it) + contentObserver = null + } + + isWatching = false + } + + private fun queryRecentFiles(): List { + val files = mutableListOf() + val projection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.MIME_TYPE, + MediaStore.Files.FileColumns.SIZE, + MediaStore.Files.FileColumns.DATE_ADDED + ) + + val selection = "${MediaStore.Files.FileColumns.SIZE} > 0" + val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC" + val limit = 100 + + context.contentResolver.query( + MediaStore.Files.getContentUri("external"), + projection, + selection, + null, + sortOrder + )?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE) + val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE) + val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED) + + var count = 0 + while (cursor.moveToNext() && count < limit) { + val id = cursor.getLong(idCol) + val name = cursor.getString(nameCol) ?: continue + val mimeType = cursor.getString(mimeCol) ?: "application/octet-stream" + val size = cursor.getLong(sizeCol) + val dateAdded = cursor.getLong(dateCol) + + val uri = Uri.withAppendedPath( + MediaStore.Files.getContentUri("external"), + id.toString() + ).toString() + + files.add(DetectedFile( + id = "media_$id", + uri = uri, + name = name, + mimeType = mimeType, + size = size, + createdAt = dateAdded * 1000L + )) + count++ + } + } + + return files + } +} diff --git a/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/MediaStoreObserver.kt b/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/MediaStoreObserver.kt new file mode 100644 index 0000000..0720700 --- /dev/null +++ b/mobile/modules/expo-download-detect/android/src/main/java/expo/modules/downloaddetect/MediaStoreObserver.kt @@ -0,0 +1,105 @@ +package expo.modules.downloaddetect + +import android.content.ContentResolver +import android.content.Context +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri +import android.os.Handler +import android.provider.MediaStore + +class MediaStoreObserver( + handler: Handler, + private val context: Context, + private val onNewFiles: (List) -> Unit +) : ContentObserver(handler) { + + private var lastScanTimestamp: Long = System.currentTimeMillis() / 1000L + + override fun onChange(selfChange: Boolean, uri: Uri?) { + super.onChange(selfChange, uri) + queryNewFiles() + } + + override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) { + super.onChange(selfChange, uri, flags) + queryNewFiles() + } + + private fun queryNewFiles() { + val files = mutableListOf() + val projection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.MIME_TYPE, + MediaStore.Files.FileColumns.SIZE, + MediaStore.Files.FileColumns.DATE_ADDED + ) + + val selection = "${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND ${MediaStore.Files.FileColumns.SIZE} > 0" + val selectionArgs = arrayOf(lastScanTimestamp.toString()) + val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC" + + val resolver: ContentResolver = context.contentResolver + var cursor: Cursor? = null + + try { + // Try MediaStore.Downloads first + cursor = try { + resolver.query( + MediaStore.Downloads.getContentUri("external"), + projection, selection, selectionArgs, sortOrder + ) + } catch (_: Exception) { + null + } + + // Fallback to MediaStore.Files if Downloads didn't work + if (cursor == null || cursor.count == 0) { + cursor?.close() + cursor = resolver.query( + MediaStore.Files.getContentUri("external"), + projection, selection, selectionArgs, sortOrder + ) + } + + cursor?.use { c -> + val idCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + val nameCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) + val mimeCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE) + val sizeCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE) + val dateCol = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED) + + while (c.moveToNext()) { + val id = c.getLong(idCol) + val name = c.getString(nameCol) ?: continue + val mimeType = c.getString(mimeCol) ?: "application/octet-stream" + val size = c.getLong(sizeCol) + val dateAdded = c.getLong(dateCol) + + val fileUri = Uri.withAppendedPath( + MediaStore.Files.getContentUri("external"), + id.toString() + ).toString() + + files.add(DetectedFile( + id = "media_$id", + uri = fileUri, + name = name, + mimeType = mimeType, + size = size, + createdAt = dateAdded * 1000L + )) + } + } + } catch (_: Exception) { + } finally { + cursor?.close() + } + + if (files.isNotEmpty()) { + lastScanTimestamp = System.currentTimeMillis() / 1000L + onNewFiles(files) + } + } +} diff --git a/mobile/modules/expo-download-detect/expo-module.config.json b/mobile/modules/expo-download-detect/expo-module.config.json new file mode 100644 index 0000000..8e019d1 --- /dev/null +++ b/mobile/modules/expo-download-detect/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.downloaddetect.ExpoDownloadDetectModule"] + } +} diff --git a/mobile/modules/expo-download-detect/index.ts b/mobile/modules/expo-download-detect/index.ts new file mode 100644 index 0000000..9a08708 --- /dev/null +++ b/mobile/modules/expo-download-detect/index.ts @@ -0,0 +1,2 @@ +export { ExpoDownloadDetectModule } from './src'; +export type { FileDetectedEvent } from './src'; diff --git a/mobile/modules/expo-download-detect/package.json b/mobile/modules/expo-download-detect/package.json new file mode 100644 index 0000000..076b8c0 --- /dev/null +++ b/mobile/modules/expo-download-detect/package.json @@ -0,0 +1,12 @@ +{ + "name": "expo-download-detect", + "version": "0.1.0", + "description": "Detect new files via DownloadManager and MediaStore ContentObserver", + "main": "index.ts", + "android": { + "sourceDir": "android" + }, + "peerDependencies": { + "expo": ">=57.0.0" + } +} diff --git a/mobile/modules/expo-download-detect/src/ExpoDownloadDetect.types.ts b/mobile/modules/expo-download-detect/src/ExpoDownloadDetect.types.ts new file mode 100644 index 0000000..950bd0f --- /dev/null +++ b/mobile/modules/expo-download-detect/src/ExpoDownloadDetect.types.ts @@ -0,0 +1,13 @@ +export type FileDetectedEvent = { + id: string; + uri: string; + name: string; + mimeType: string; + size: number; + createdAt: number; + source: 'download' | 'mediastore' | 'startup'; +}; + +export type ExpoDownloadDetectModuleEvents = { + onNewFile: (event: FileDetectedEvent) => void; +}; diff --git a/mobile/modules/expo-download-detect/src/ExpoDownloadDetectModule.ts b/mobile/modules/expo-download-detect/src/ExpoDownloadDetectModule.ts new file mode 100644 index 0000000..6d5ffb3 --- /dev/null +++ b/mobile/modules/expo-download-detect/src/ExpoDownloadDetectModule.ts @@ -0,0 +1,10 @@ +import { NativeModule, requireNativeModule } from 'expo'; +import { ExpoDownloadDetectModuleEvents, FileDetectedEvent } from './ExpoDownloadDetect.types'; + +declare class ExpoDownloadDetectModule extends NativeModule { + startWatching(): void; + stopWatching(): void; + getRecentDownloads(): Promise; +} + +export default requireNativeModule('ExpoDownloadDetect'); diff --git a/mobile/modules/expo-download-detect/src/index.ts b/mobile/modules/expo-download-detect/src/index.ts new file mode 100644 index 0000000..09c04f6 --- /dev/null +++ b/mobile/modules/expo-download-detect/src/index.ts @@ -0,0 +1,2 @@ +export { default as ExpoDownloadDetectModule } from './ExpoDownloadDetectModule'; +export type { FileDetectedEvent, ExpoDownloadDetectModuleEvents } from './ExpoDownloadDetect.types'; diff --git a/mobile/services/downloadRegistry.ts b/mobile/services/downloadRegistry.ts new file mode 100644 index 0000000..49a9275 --- /dev/null +++ b/mobile/services/downloadRegistry.ts @@ -0,0 +1,98 @@ +import { createMMKV } from 'react-native-mmkv'; +import { FileDetectedEvent } from '../modules/expo-download-detect'; + +const storage = createMMKV({ id: 'vaultdrop-download-registry' }); + +const FILES_KEY = 'detected_files'; + +export type DownloadRegistryEntry = { + id: string; + uri: string; + name: string; + mimeType: string; + size: number; + createdAt: number; + source: string; + detectedAt: number; +}; + +function getAllRaw(): DownloadRegistryEntry[] { + const raw = storage.getString(FILES_KEY); + if (!raw) return []; + return JSON.parse(raw) as DownloadRegistryEntry[]; +} + +function saveAll(entries: DownloadRegistryEntry[]) { + storage.set(FILES_KEY, JSON.stringify(entries)); +} + +export const downloadRegistry = { + getAll(): DownloadRegistryEntry[] { + return getAllRaw(); + }, + + getById(id: string): DownloadRegistryEntry | undefined { + return getAllRaw().find((e) => e.id === id); + }, + + add(event: FileDetectedEvent): DownloadRegistryEntry | null { + const entries = getAllRaw(); + if (entries.some((e) => e.id === event.id)) return null; + + const entry: DownloadRegistryEntry = { + id: event.id, + uri: event.uri, + name: event.name, + mimeType: event.mimeType, + size: event.size, + createdAt: event.createdAt, + source: event.source, + detectedAt: Date.now(), + }; + + entries.push(entry); + saveAll(entries); + return entry; + }, + + addBatch(events: FileDetectedEvent[]): DownloadRegistryEntry[] { + const entries = getAllRaw(); + const existingIds = new Set(entries.map((e) => e.id)); + const newEntries: DownloadRegistryEntry[] = []; + + for (const event of events) { + if (existingIds.has(event.id)) continue; + const entry: DownloadRegistryEntry = { + id: event.id, + uri: event.uri, + name: event.name, + mimeType: event.mimeType, + size: event.size, + createdAt: event.createdAt, + source: event.source, + detectedAt: Date.now(), + }; + newEntries.push(entry); + existingIds.add(event.id); + } + + if (newEntries.length > 0) { + saveAll([...entries, ...newEntries]); + } + + return newEntries; + }, + + remove(id: string) { + const entries = getAllRaw().filter((e) => e.id !== id); + saveAll(entries); + }, + + clear() { + storage.remove(FILES_KEY); + }, + + count(): number { + return getAllRaw().length; + }, +}; diff --git a/mobile/services/onboardingStorage.ts b/mobile/services/onboardingStorage.ts new file mode 100644 index 0000000..2984dd1 --- /dev/null +++ b/mobile/services/onboardingStorage.ts @@ -0,0 +1,55 @@ +import { createMMKV } from 'react-native-mmkv'; +import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding'; +import { safDirectory } from './safDirectory'; + +const storage = createMMKV({ id: 'vaultdrop-onboarding' }); + +const COMPLETED_VERSION_KEY = 'completed_version'; +const SEEN_STEPS_KEY = 'seen_steps'; + +export const onboardingStorage = { + getCompletedVersion(): number | undefined { + const raw = storage.getNumber(COMPLETED_VERSION_KEY); + return raw != null ? raw : undefined; + }, + + setCompletedVersion(version: number) { + storage.set(COMPLETED_VERSION_KEY, version); + }, + + getSeenSteps(): string[] { + const raw = storage.getString(SEEN_STEPS_KEY); + if (!raw) return []; + return JSON.parse(raw) as string[]; + }, + + markStepSeen(stepId: string) { + const seen = this.getSeenSteps(); + if (!seen.includes(stepId)) { + seen.push(stepId); + storage.set(SEEN_STEPS_KEY, JSON.stringify(seen)); + } + }, + + getPendingSteps(): OnboardingStep[] { + const lastVersion = this.getCompletedVersion(); + const seenIds = this.getSeenSteps(); + const folders = safDirectory.getAll(); + + return ONBOARDING_STEPS.filter((step) => { + if (lastVersion != null && step.version <= lastVersion) return false; + if (seenIds.includes(step.id)) return false; + if (step.condition === 'has_no_folders' && folders.length > 0) return false; + return true; + }); + }, + + needsOnboarding(): boolean { + return this.getPendingSteps().length > 0; + }, + + reset() { + storage.remove(COMPLETED_VERSION_KEY); + storage.remove(SEEN_STEPS_KEY); + }, +}; diff --git a/mobile/services/safDirectory.ts b/mobile/services/safDirectory.ts new file mode 100644 index 0000000..e6bd1be --- /dev/null +++ b/mobile/services/safDirectory.ts @@ -0,0 +1,63 @@ +import { createMMKV } from 'react-native-mmkv'; + +export type StoredFolder = { + id: string; + uri: string; + name: string; + visible: boolean; +}; + +const storage = createMMKV({ id: 'vaultdrop-saf' }); + +const FOLDERS_KEY = 'saf_folders'; + +function generateId(): string { + return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +function getAllRaw(): StoredFolder[] { + const raw = storage.getString(FOLDERS_KEY); + if (!raw) return []; + return JSON.parse(raw) as StoredFolder[]; +} + +function saveAll(folders: StoredFolder[]) { + storage.set(FOLDERS_KEY, JSON.stringify(folders)); +} + +export const safDirectory = { + getAll(): StoredFolder[] { + return getAllRaw(); + }, + + getVisibleFolders(): StoredFolder[] { + return getAllRaw().filter((f) => f.visible); + }, + + addFolder(uri: string, name: string): StoredFolder { + const folders = getAllRaw(); + if (folders.some((f) => f.uri === uri)) { + return folders.find((f) => f.uri === uri)!; + } + const folder: StoredFolder = { id: generateId(), uri, name, visible: true }; + folders.push(folder); + saveAll(folders); + return folder; + }, + + removeFolder(id: string) { + const folders = getAllRaw().filter((f) => f.id !== id); + saveAll(folders); + }, + + toggleVisibility(id: string) { + const folders = getAllRaw().map((f) => + f.id === id ? { ...f, visible: !f.visible } : f + ); + saveAll(folders); + }, + + clear() { + storage.remove(FOLDERS_KEY); + }, +};