add files listing
This commit is contained in:
+175
-11
@@ -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<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() {
|
||||
const [files, setFiles] = useState<DeviceFile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
const [folders, setFolders] = useState<StoredFolder[]>(() => 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 {
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user