add files listing
This commit is contained in:
@@ -40,3 +40,6 @@ yarn-error.*
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
modules/*/android/build/
|
||||
modules/*/android/.gradle/
|
||||
+10
-1
@@ -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 (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator initialRouteName={user ? 'Home' : 'Login'}>
|
||||
<Stack.Navigator initialRouteName={needsOnboarding ? 'Onboarding' : user ? 'Home' : 'Login'}>
|
||||
{user ? (
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="Onboarding"
|
||||
component={OnboardingScreen}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
|
||||
+3
-1
@@ -20,7 +20,9 @@
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE",
|
||||
"READ_MEDIA_IMAGES",
|
||||
"READ_MEDIA_VIDEO"
|
||||
"READ_MEDIA_VIDEO",
|
||||
"READ_MEDIA_AUDIO",
|
||||
"RECEIVE_BOOT_COMPLETED"
|
||||
],
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
|
||||
+25
-15
@@ -30,25 +30,35 @@ type SelectedImage = { uri: string; width: number; height: number };
|
||||
|
||||
type ModalState = { images: SelectedImage[]; index: number } | null;
|
||||
|
||||
export type DeviceFileParam = {
|
||||
localUri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type RootStackParamList = {
|
||||
FileDetail: { fileIds: string[]; initialIndex: number };
|
||||
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, DeviceFileParam> };
|
||||
};
|
||||
|
||||
type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
|
||||
|
||||
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?:
|
||||
|
||||
<View style={styles.details}>
|
||||
<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} />
|
||||
</View>
|
||||
|
||||
{imageData?.data?.size != null && (
|
||||
{(deviceFile || imageData?.data?.size != null) && (
|
||||
<Text style={styles.meta}>
|
||||
Taille : {(imageData.data.size / 1024).toFixed(1)} Ko
|
||||
Taille : {((deviceFile ? 0 : imageData?.data?.size) ?? 0 / 1024).toFixed(1)} Ko
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{file?.createdAt && (
|
||||
{(deviceFile?.createdAt || file?.createdAt) && (
|
||||
<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',
|
||||
})}
|
||||
</Text>
|
||||
@@ -183,7 +193,7 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
|
||||
|
||||
export function FileDetailScreen() {
|
||||
const route = useRoute<FileDetailRouteProp>();
|
||||
const { fileIds, initialIndex } = route.params;
|
||||
const { fileIds, initialIndex, deviceFiles } = route.params;
|
||||
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
@@ -223,7 +233,7 @@ export function FileDetailScreen() {
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.pageWrapper}>
|
||||
<DetailItem fileId={item} onSelectImage={setModalState} />
|
||||
<DetailItem fileId={item} deviceFile={deviceFiles?.[item]} onSelectImage={setModalState} />
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
+174
-2
@@ -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<string, { localUri: string; name: string; mimeType: string; createdAt: string }> };
|
||||
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<NavigationProp>();
|
||||
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: () => (
|
||||
<TouchableOpacity onPress={() => setFolderSheetVisible(true)} style={{ marginRight: 4, padding: 8 }}>
|
||||
<MaterialIcons name="settings" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [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<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', {
|
||||
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() {
|
||||
</TouchableOpacity>
|
||||
</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
|
||||
data={groupKeys}
|
||||
keyExtractor={(item) => item}
|
||||
@@ -528,6 +583,45 @@ export function HomeScreen() {
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
+77
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -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
|
||||
}
|
||||
}
|
||||
+105
@@ -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';
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user