add files listing

This commit is contained in:
m
2026-07-27 20:50:23 +02:00
parent 0cc261105a
commit 5c85ffa5fe
25 changed files with 1312 additions and 32 deletions
+3
View File
@@ -40,3 +40,6 @@ yarn-error.*
# generated native folders # generated native folders
/ios /ios
/android /android
modules/*/android/build/
modules/*/android/.gradle/
+10 -1
View File
@@ -17,6 +17,8 @@ import { PendingReviewScreen } from './app/pending-review';
import { FileDetailScreen } from './app/file-detail'; import { FileDetailScreen } from './app/file-detail';
import { FileEditScreen } from './app/file-edit'; import { FileEditScreen } from './app/file-edit';
import { FolderScreen } from './app/folder'; import { FolderScreen } from './app/folder';
import { OnboardingScreen } from './app/onboarding';
import { onboardingStorage } from './services/onboardingStorage';
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -32,11 +34,18 @@ function AppNavigator() {
); );
} }
const needsOnboarding = user && onboardingStorage.needsOnboarding();
return ( return (
<NavigationContainer> <NavigationContainer>
<Stack.Navigator initialRouteName={user ? 'Home' : 'Login'}> <Stack.Navigator initialRouteName={needsOnboarding ? 'Onboarding' : user ? 'Home' : 'Login'}>
{user ? ( {user ? (
<> <>
<Stack.Screen
name="Onboarding"
component={OnboardingScreen}
options={{ headerShown: false }}
/>
<Stack.Screen <Stack.Screen
name="Home" name="Home"
component={HomeScreen} component={HomeScreen}
+3 -1
View File
@@ -20,7 +20,9 @@
"READ_EXTERNAL_STORAGE", "READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE",
"READ_MEDIA_IMAGES", "READ_MEDIA_IMAGES",
"READ_MEDIA_VIDEO" "READ_MEDIA_VIDEO",
"READ_MEDIA_AUDIO",
"RECEIVE_BOOT_COMPLETED"
], ],
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
+25 -15
View File
@@ -30,25 +30,35 @@ type SelectedImage = { uri: string; width: number; height: number };
type ModalState = { images: SelectedImage[]; index: number } | null; type ModalState = { images: SelectedImage[]; index: number } | null;
export type DeviceFileParam = {
localUri: string;
name: string;
mimeType: string;
createdAt: string;
};
type RootStackParamList = { type RootStackParamList = {
FileDetail: { fileIds: string[]; initialIndex: number }; FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, DeviceFileParam> };
}; };
type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>; type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (state: ModalState) => void }) { function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; deviceFile?: DeviceFileParam; onSelectImage?: (state: ModalState) => void }) {
const { data: imageData, isLoading: imageLoading } = useFileImage(fileId); const isDevice = !!deviceFile;
const { data: fileData } = useFile(fileId); const { data: imageData, isLoading: imageLoading } = useFileImage(isDevice ? '' : fileId);
const { data: fileData } = useFile(isDevice ? '' : fileId);
const downloadFile = useDownloadFile(); const downloadFile = useDownloadFile();
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const uri = imageData?.data?.url; const uri = isDevice ? deviceFile.localUri : (imageData?.data?.url);
const file = fileData as any; const file = fileData as any;
const localEntry = localFileRegistry.getByBackendId(fileId); const localEntry = localFileRegistry.getByBackendId(fileId);
const syncStatus: SyncStatus = localEntry const syncStatus: SyncStatus = isDevice
? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced') ? 'local'
: (uri ? 'synced' : 'cloud'); : localEntry
? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced')
: (uri ? 'synced' : 'cloud');
const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? []) const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? [])
.filter((t: Thumbnail) => t.resolutionLabel === 'full') .filter((t: Thumbnail) => t.resolutionLabel === 'full')
@@ -141,19 +151,19 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
<View style={styles.details}> <View style={styles.details}>
<View style={styles.detailHeader}> <View style={styles.detailHeader}>
<Text style={styles.fileName}>{imageData?.data?.name ?? file?.name ?? fileId}</Text> <Text style={styles.fileName}>{deviceFile?.name ?? imageData?.data?.name ?? file?.name ?? fileId}</Text>
<SyncStatusBadge status={syncStatus} size={20} /> <SyncStatusBadge status={syncStatus} size={20} />
</View> </View>
{imageData?.data?.size != null && ( {(deviceFile || imageData?.data?.size != null) && (
<Text style={styles.meta}> <Text style={styles.meta}>
Taille : {(imageData.data.size / 1024).toFixed(1)} Ko Taille : {((deviceFile ? 0 : imageData?.data?.size) ?? 0 / 1024).toFixed(1)} Ko
</Text> </Text>
)} )}
{file?.createdAt && ( {(deviceFile?.createdAt || file?.createdAt) && (
<Text style={styles.meta}> <Text style={styles.meta}>
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', day: 'numeric', month: 'long', year: 'numeric',
})} })}
</Text> </Text>
@@ -183,7 +193,7 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
export function FileDetailScreen() { export function FileDetailScreen() {
const route = useRoute<FileDetailRouteProp>(); const route = useRoute<FileDetailRouteProp>();
const { fileIds, initialIndex } = route.params; const { fileIds, initialIndex, deviceFiles } = route.params;
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
@@ -223,7 +233,7 @@ export function FileDetailScreen() {
}} }}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={styles.pageWrapper}> <View style={styles.pageWrapper}>
<DetailItem fileId={item} onSelectImage={setModalState} /> <DetailItem fileId={item} deviceFile={deviceFiles?.[item]} onSelectImage={setModalState} />
</View> </View>
)} )}
/> />
+174 -2
View File
@@ -10,6 +10,7 @@ import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
import { FileItem, isFolder } from '../types'; import { FileItem, isFolder } from '../types';
import { SearchBar, SearchFilters } from '../components/SearchBar'; import { SearchBar, SearchFilters } from '../components/SearchBar';
import { FileThumbnail } from '../components/FileThumbnail'; import { FileThumbnail } from '../components/FileThumbnail';
import { safDirectory, StoredFolder } from '../services/safDirectory';
const NUM_COLUMNS = 3; const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width; const SCREEN_WIDTH = Dimensions.get('window').width;
@@ -19,7 +20,7 @@ type RootStackParamList = {
Home: undefined; Home: undefined;
Upload: undefined; Upload: undefined;
Scan: undefined; Scan: undefined;
FileDetail: { fileIds: string[]; initialIndex: number }; FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> };
FileEdit: { fileIds: string[] }; FileEdit: { fileIds: string[] };
Folder: { folderId: string; folderName: string }; Folder: { folderId: string; folderName: string };
}; };
@@ -133,7 +134,7 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte
export function HomeScreen() { export function HomeScreen() {
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { data, isLoading, error, hasPermission, requestPermission } = useUnifiedFiles(); const { data, isLoading, error, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useUnifiedFiles();
const deleteFile = useDeleteFile(); const deleteFile = useDeleteFile();
const freeLocalSpace = useFreeLocalSpace(); const freeLocalSpace = useFreeLocalSpace();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -148,6 +149,7 @@ export function HomeScreen() {
const moveFiles = useMoveFiles(); const moveFiles = useMoveFiles();
const { data: foldersData } = useFolders(); const { data: foldersData } = useFolders();
const [moveModalVisible, setMoveModalVisible] = useState(false); const [moveModalVisible, setMoveModalVisible] = useState(false);
const [folderSheetVisible, setFolderSheetVisible] = useState(false);
useEffect(() => { useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)); const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
@@ -155,6 +157,16 @@ export function HomeScreen() {
return () => { show.remove(); hide.remove(); }; return () => { show.remove(); hide.remove(); };
}, []); }, []);
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => setFolderSheetVisible(true)} style={{ marginRight: 4, padding: 8 }}>
<MaterialIcons name="settings" size={22} color="#666" />
</TouchableOpacity>
),
});
}, [navigation]);
const selectionMode = selectedIds.size > 0; const selectionMode = selectedIds.size > 0;
const files = data ?? []; const files = data ?? [];
@@ -269,15 +281,50 @@ export function HomeScreen() {
setSelectedIds(new Set()); setSelectedIds(new Set());
}, [selectedIds, moveFiles]); }, [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) => { const handleItemPress = useCallback((file: UnifiedFileItem) => {
if (selectionMode) { if (selectionMode) {
toggleSelection(file.id); toggleSelection(file.id);
} else if (isFolder(file)) { } else if (isFolder(file)) {
navigation.navigate('Folder', { folderId: file.id, folderName: file.name }); navigation.navigate('Folder', { folderId: file.id, folderName: file.name });
} else { } else {
const deviceFilesMap: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> = {};
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', { navigation.navigate('FileDetail', {
fileIds: filteredFiles.map((f) => f.id), fileIds: filteredFiles.map((f) => f.id),
initialIndex: fileIdToIndex.get(file.id) ?? 0, initialIndex: fileIdToIndex.get(file.id) ?? 0,
deviceFiles: Object.keys(deviceFilesMap).length > 0 ? deviceFilesMap : undefined,
}); });
} }
}, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]); }, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]);
@@ -333,6 +380,14 @@ export function HomeScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
{files.length === 0 && !searchQuery && (
<TouchableOpacity style={styles.folderScanBanner} onPress={pickDirectory}>
<MaterialIcons name="folder-open" size={24} color="#F57C00" />
<Text style={styles.folderScanText}>
{folders.length > 0 ? 'Aucun fichier trouvé — re-sélectionner' : 'Scanner un dossier de votre appareil'}
</Text>
</TouchableOpacity>
)}
<FlatList <FlatList
data={groupKeys} data={groupKeys}
keyExtractor={(item) => item} keyExtractor={(item) => item}
@@ -528,6 +583,45 @@ export function HomeScreen() {
</TouchableOpacity> </TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
</Modal> </Modal>
<Modal visible={folderSheetVisible} transparent animationType="slide" onRequestClose={() => setFolderSheetVisible(false)}>
<TouchableOpacity style={styles.bottomSheetOverlay} activeOpacity={1} onPress={() => setFolderSheetVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.bottomSheet} onPress={() => {}}>
<View style={styles.bottomSheetHandle} />
<Text style={styles.bottomSheetTitle}>Mes dossiers</Text>
{folders.length === 0 ? (
<Text style={styles.emptyFoldersText}>Aucun dossier configuré</Text>
) : (
folders.map((folder) => (
<View key={folder.id} style={styles.folderRow}>
<MaterialIcons name="folder" size={20} color="#F57C00" />
<Text style={styles.folderRowName} numberOfLines={1}>{folder.name}</Text>
<TouchableOpacity
onPress={() => handleToggleFolderVisibility(folder.id)}
style={styles.folderRowAction}
>
<MaterialIcons
name={folder.visible ? 'visibility' : 'visibility-off'}
size={20}
color={folder.visible ? '#1976D2' : '#999'}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleRemoveFolder(folder.id)}
style={styles.folderRowAction}
>
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
</TouchableOpacity>
</View>
))
)}
<TouchableOpacity style={styles.addFolderBtn} onPress={handleAddFolder}>
<MaterialIcons name="add" size={20} color="#fff" />
<Text style={styles.addFolderBtnText}>Ajouter un dossier</Text>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
} }
@@ -561,6 +655,19 @@ const styles = StyleSheet.create({
color: '#fff', color: '#fff',
fontWeight: '600', fontWeight: '600',
}, },
folderScanBanner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF3E0',
paddingHorizontal: 16,
paddingVertical: 14,
gap: 10,
},
folderScanText: {
flex: 1,
fontSize: 14,
color: '#333',
},
center: { center: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
@@ -777,4 +884,69 @@ const styles = StyleSheet.create({
color: '#fff', color: '#fff',
fontWeight: '600', 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,
},
}); });
+210
View File
@@ -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<string, keyof typeof MaterialIcons.glyphMap> = {
'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 (
<View style={styles.container}>
<View style={styles.skipContainer}>
<TouchableOpacity onPress={handleSkip}>
<Text style={styles.skipText}>Passer</Text>
</TouchableOpacity>
</View>
<View style={styles.content}>
<View style={styles.iconContainer}>
<MaterialIcons
name={ICON_MAP[step.icon] ?? 'info'}
size={64}
color="#1976D2"
/>
</View>
<Text style={styles.title}>{step.title}</Text>
<Text style={styles.description}>{step.description}</Text>
{step.action?.type === 'pick_directory' && (
<TouchableOpacity style={styles.actionBtn} onPress={handlePickDirectory}>
<MaterialIcons name="folder-open" size={20} color="#fff" />
<Text style={styles.actionBtnText}>{step.action.label}</Text>
</TouchableOpacity>
)}
</View>
<View style={styles.footer}>
<View style={styles.dots}>
{pendingSteps.map((_, i) => (
<View
key={i}
style={[styles.dot, i === currentIndex && styles.dotActive]}
/>
))}
</View>
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
<Text style={styles.nextBtnText}>
{currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'}
</Text>
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
</TouchableOpacity>
</View>
</View>
);
}
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',
},
});
+42
View File
@@ -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',
},
];
+175 -11
View File
@@ -1,6 +1,11 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import * as MediaLibrary from 'expo-media-library/legacy'; import * as MediaLibrary from 'expo-media-library/legacy';
import * as FileSystem from 'expo-file-system/legacy';
import { LocalFileEntry } from '../types'; 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 { export interface DeviceFile {
id: string; id: string;
@@ -9,29 +14,92 @@ export interface DeviceFile {
mimeType: string; mimeType: string;
size: number; size: number;
createdAt: string; createdAt: string;
folderId?: string;
}
function guessMimeType(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
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<DeviceFile[]> {
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() { export function useDeviceFiles() {
const [files, setFiles] = useState<DeviceFile[]>([]); const [files, setFiles] = useState<DeviceFile[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [hasPermission, setHasPermission] = useState(false); const [hasPermission, setHasPermission] = useState(false);
const [folders, setFolders] = useState<StoredFolder[]>(() => safDirectory.getAll());
const { newFiles, clearNewFiles } = useFileWatcher();
const requestPermission = useCallback(async () => { const requestPermission = useCallback(async () => {
const req = await MediaLibrary.requestPermissionsAsync(); const req = await MediaLibrary.requestPermissionsAsync();
console.log('[useDeviceFiles] requestPermission status:', req.status, 'granted:', req.granted, 'canAskAgain:', req.canAskAgain);
if (req.granted) { if (req.granted) {
setHasPermission(true); setHasPermission(true);
return true; return true;
} }
const check = await MediaLibrary.getPermissionsAsync(); const check = await MediaLibrary.getPermissionsAsync();
console.log('[useDeviceFiles] getPermissionsAsync status:', check.status, 'granted:', check.granted);
const granted = check.granted; const granted = check.granted;
setHasPermission(granted); setHasPermission(granted);
return granted; return granted;
}, []); }, []);
const loadAssets = useCallback(async () => { const loadAssets = useCallback(async () => {
console.log('[useDeviceFiles] loadAssets called');
setIsLoading(true); setIsLoading(true);
try { try {
const result = await MediaLibrary.getAssetsAsync({ const result = await MediaLibrary.getAssetsAsync({
@@ -52,7 +120,6 @@ export function useDeviceFiles() {
})); }));
setFiles(deviceFiles); setFiles(deviceFiles);
console.log('[useDeviceFiles] loaded', deviceFiles.length, 'files');
} catch (err) { } catch (err) {
console.error('[useDeviceFiles] scan error:', err); console.error('[useDeviceFiles] scan error:', err);
setFiles([]); setFiles([]);
@@ -61,23 +128,120 @@ export function useDeviceFiles() {
} }
}, []); }, []);
useEffect(() => { const scanVisibleFolders = useCallback(async () => {
requestPermission().then((granted) => { const visibleFolders = safDirectory.getVisibleFolders();
console.log('[useDeviceFiles] mount permission result:', granted); if (visibleFolders.length === 0) return;
if (granted) {
loadAssets(); 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 rescan = useCallback(async () => {
const granted = await requestPermission(); const granted = await requestPermission();
if (granted) { if (granted) {
await loadAssets(); 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 { export function deviceFileToLocalEntry(deviceFile: DeviceFile): LocalFileEntry {
+32
View File
@@ -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<FileDetectedEvent[]>([]);
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 };
}
+4 -1
View File
@@ -4,7 +4,7 @@ import { localFileRegistry } from '../services/localFileRegistry';
import { LocalFileEntry } from '../types'; import { LocalFileEntry } from '../types';
export function useLocalFiles() { 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(), []); const registryEntries = useMemo(() => localFileRegistry.getAll(), []);
@@ -40,5 +40,8 @@ export function useLocalFiles() {
hasPermission, hasPermission,
requestPermission, requestPermission,
rescan, rescan,
pickDirectory,
folders,
refreshFolders,
}; };
} }
+4 -1
View File
@@ -43,7 +43,7 @@ function getExtension(name: string): string {
export function useUnifiedFiles(page: number = 1, limit: number = 50) { export function useUnifiedFiles(page: number = 1, limit: number = 50) {
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit); 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 unifiedFiles = useMemo(() => {
const backendFiles = backendData?.data ?? []; const backendFiles = backendData?.data ?? [];
@@ -110,6 +110,9 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
error: backendError, error: backendError,
hasPermission, hasPermission,
requestPermission, requestPermission,
pickDirectory,
folders,
refreshFolders,
}; };
} }
@@ -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"
}
}
@@ -0,0 +1,3 @@
<manifest>
</manifest>
@@ -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
}
}
}
@@ -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<DetectedFile> {
val files = mutableListOf<DetectedFile>()
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
}
}
@@ -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<DetectedFile>) -> 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<DetectedFile>()
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)
}
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.downloaddetect.ExpoDownloadDetectModule"]
}
}
@@ -0,0 +1,2 @@
export { ExpoDownloadDetectModule } from './src';
export type { FileDetectedEvent } from './src';
@@ -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"
}
}
@@ -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;
};
@@ -0,0 +1,10 @@
import { NativeModule, requireNativeModule } from 'expo';
import { ExpoDownloadDetectModuleEvents, FileDetectedEvent } from './ExpoDownloadDetect.types';
declare class ExpoDownloadDetectModule extends NativeModule<ExpoDownloadDetectModuleEvents> {
startWatching(): void;
stopWatching(): void;
getRecentDownloads(): Promise<FileDetectedEvent[]>;
}
export default requireNativeModule<ExpoDownloadDetectModule>('ExpoDownloadDetect');
@@ -0,0 +1,2 @@
export { default as ExpoDownloadDetectModule } from './ExpoDownloadDetectModule';
export type { FileDetectedEvent, ExpoDownloadDetectModuleEvents } from './ExpoDownloadDetect.types';
+98
View File
@@ -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;
},
};
+55
View File
@@ -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);
},
};
+63
View File
@@ -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);
},
};