use auto sync

This commit is contained in:
m
2026-08-29 09:31:53 +02:00
parent e43aa2d793
commit 479c8ab61c
5 changed files with 327 additions and 93 deletions
+2 -2
View File
@@ -28,11 +28,11 @@ import { onboardingStorage } from './services/onboardingStorage';
import { initDB } from './services/fileStore'; import { initDB } from './services/fileStore';
import { migrateFromLegacy } from './services/fileStore/migrate'; import { migrateFromLegacy } from './services/fileStore/migrate';
import { createMMKVPersister } from './services/mmkvPersister'; import { createMMKVPersister } from './services/mmkvPersister';
import { setIsSyncing } from './hooks/useSyncQueue'; import { resetSyncState } from './hooks/useSyncQueue';
initDB(); initDB();
migrateFromLegacy(); migrateFromLegacy();
setIsSyncing(false); resetSyncState();
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
+170 -73
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useCallback, useState, useEffect } from 'react'; import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import { import {
View, View,
Text, Text,
@@ -7,12 +7,12 @@ import {
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Animated,
Easing,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { fileStore, FileRecord } from '../services/fileStore'; import { fileStore, FileRecord } from '../services/fileStore';
import { safDirectory } from '../services/safDirectory'; import { useSyncQueue, useSyncProgress } from '../hooks/useSyncQueue';
import { useSyncQueue } from '../hooks/useSyncQueue';
import { useAutoSync } from '../hooks/useAutoSync'; import { useAutoSync } from '../hooks/useAutoSync';
import { useSyncPush } from '../hooks/useSyncPush'; import { useSyncPush } from '../hooks/useSyncPush';
import { useUploadQueue } from '../hooks/useUploadQueue'; import { useUploadQueue } from '../hooks/useUploadQueue';
@@ -31,10 +31,117 @@ function uploadStatusIcon(task: UploadTask) {
} }
} }
function SpinningSyncIcon({ size, color }: { size: number; color: string }) {
const spin = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.timing(spin, {
toValue: 1,
duration: 1200,
easing: Easing.linear,
useNativeDriver: true,
})
);
animation.start();
return () => animation.stop();
}, [spin]);
const rotate = spin.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
return (
<Animated.View style={{ transform: [{ rotate }] }}>
<MaterialIcons name="sync" size={size} color={color} />
</Animated.View>
);
}
type PendingFile = { id: string; name: string };
const PendingSyncCard = React.memo(function PendingSyncCard({
syncing,
pendingCount,
shortList,
onSyncPress,
onCancel,
}: {
syncing: boolean;
pendingCount: number;
shortList: PendingFile[] | null;
onSyncPress: () => void;
onCancel: () => void;
}) {
return (
<View style={styles.pendingCard}>
<TouchableOpacity
style={styles.pendingCardTop}
onPress={syncing ? undefined : onSyncPress}
disabled={syncing}
activeOpacity={0.8}
>
<View style={styles.pendingCardIcon}>
{syncing ? (
<SpinningSyncIcon size={28} color="#1976D2" />
) : (
<MaterialIcons name="cloud-upload" size={28} color="#1976D2" />
)}
</View>
<View style={styles.pendingCardInfo}>
<Text style={styles.pendingCardTitle}>
{syncing
? 'Synchronisation en cours...'
: pendingCount > 1
? `Vous avez ${pendingCount} fichiers locaux pouvant être synchronisés`
: 'Vous avez 1 fichier local pouvant être synchronisé'}
</Text>
<Text style={styles.pendingCardSubtitle}>
{syncing
? shortList && shortList[0]
? `En cours : ${shortList[0].name}`
: 'Synchronisation en cours...'
: 'Appuyer maintenant pour les synchroniser'}
</Text>
</View>
{!syncing && <MaterialIcons name="chevron-right" size={24} color="#999" />}
</TouchableOpacity>
{syncing && shortList && shortList.length > 0 && (
<View style={styles.pendingList}>
{shortList.map((f, i) => (
<View key={f.id} style={styles.pendingRow}>
{i === 0 ? (
<SpinningSyncIcon size={16} color="#1976D2" />
) : (
<MaterialIcons name="schedule" size={16} color="#FFA000" />
)}
<Text
style={[styles.pendingRowText, i === 0 && styles.pendingRowActive]}
numberOfLines={1}
>
{f.name}
</Text>
</View>
))}
</View>
)}
{syncing && (
<TouchableOpacity style={styles.stopBtn} onPress={onCancel} activeOpacity={0.8}>
<MaterialIcons name="stop-circle" size={18} color="#fff" />
<Text style={styles.stopBtnText}>Arrêter la synchronisation</Text>
</TouchableOpacity>
)}
</View>
);
});
export function SyncDetailScreen() { export function SyncDetailScreen() {
const insets = useSafeAreaInsets();
const { pendingCount, isSyncing, refresh } = useSyncQueue(); const { pendingCount, isSyncing, refresh } = useSyncQueue();
const { syncManually } = useAutoSync(); const { syncProgress } = useSyncProgress();
const { syncManually, cancelSync } = useAutoSync();
const { push } = useSyncPush(); const { push } = useSyncPush();
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue(); const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
@@ -107,37 +214,21 @@ export function SyncDetailScreen() {
); );
}, [retry]); }, [retry]);
const syncing = !!syncProgress;
const hasUploads = uploadTasks.length > 0; const hasUploads = uploadTasks.length > 0;
const hasPending = pendingCount > 0; const hasPending = pendingCount > 0 || syncing;
const hasErrors = errorFiles.length > 0; const hasErrors = errorFiles.length > 0;
const hasContent = hasUploads || hasPending || hasErrors; const hasContent = hasUploads || hasPending || hasErrors;
const shortList = useMemo(() => {
if (!syncProgress) return null;
const { files, currentIndex } = syncProgress;
const start = Math.max(0, currentIndex);
return files.slice(start, start + 5);
}, [syncProgress]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
{(hasPending || hasErrors || uploadErrors.length > 0) && (
<TouchableOpacity
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
onPress={handleSyncButtonPress}
disabled={isSyncing}
>
{isSyncing ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<MaterialIcons
name={uploadErrors.length > 0 ? "refresh" : "cloud-upload"}
size={20}
color="#fff"
/>
)}
<Text style={styles.syncBtnText}>
{isSyncing ? 'Synchronisation...'
: uploadErrors.length > 0
? `Tout réessayer (${uploadErrors.length + (hasPending ? pendingCount : 0)})`
: `Synchroniser (${pendingCount})`}
</Text>
</TouchableOpacity>
)}
{!hasContent ? ( {!hasContent ? (
<View style={styles.empty}> <View style={styles.empty}>
<MaterialIcons name="cloud-done" size={48} color="#4CAF50" /> <MaterialIcons name="cloud-done" size={48} color="#4CAF50" />
@@ -162,33 +253,20 @@ export function SyncDetailScreen() {
: item.label : item.label
: item.data.id : item.data.id
} }
extraData={[isSyncing, syncProgress]}
renderItem={({ item }) => { renderItem={({ item }) => {
if (item.type === 'section') { if (item.type === 'section') {
return <Text style={styles.headerText}>{item.label}</Text>; return <Text style={styles.headerText}>{item.label}</Text>;
} }
if (item.type === 'pendingCard') { if (item.type === 'pendingCard') {
return ( return (
<TouchableOpacity <PendingSyncCard
style={styles.pendingCard} syncing={syncing}
onPress={handleSyncButtonPress} pendingCount={pendingCount}
disabled={isSyncing} shortList={shortList}
activeOpacity={0.8} onSyncPress={handleSyncButtonPress}
> onCancel={cancelSync}
<View style={styles.pendingCardIcon}> />
<MaterialIcons name="cloud-upload" size={28} color="#1976D2" />
</View>
<View style={styles.pendingCardInfo}>
<Text style={styles.pendingCardTitle}>
{pendingCount > 1
? `Vous avez ${pendingCount} fichiers locaux pouvant être synchronisés`
: 'Vous avez 1 fichier local pouvant être synchronisé'}
</Text>
<Text style={styles.pendingCardSubtitle}>
Appuyer maintenant pour les synchroniser
</Text>
</View>
<MaterialIcons name="chevron-right" size={24} color="#999" />
</TouchableOpacity>
); );
} }
if (item.type === 'upload') { if (item.type === 'upload') {
@@ -253,25 +331,6 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: '#f5f5f5', backgroundColor: '#f5f5f5',
}, },
syncBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1976D2',
marginHorizontal: 16,
marginTop: 16,
borderRadius: 10,
paddingVertical: 14,
gap: 10,
},
syncBtnDisabled: {
backgroundColor: '#90CAF9',
},
syncBtnText: {
fontSize: 16,
color: '#fff',
fontWeight: '600',
},
list: { list: {
padding: 16, padding: 16,
}, },
@@ -290,8 +349,6 @@ const styles = StyleSheet.create({
gap: 12, gap: 12,
}, },
pendingCard: { pendingCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff', backgroundColor: '#fff',
borderRadius: 14, borderRadius: 14,
padding: 16, padding: 16,
@@ -303,6 +360,11 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
elevation: 2, elevation: 2,
}, },
pendingCardTop: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
},
pendingCardIcon: { pendingCardIcon: {
width: 48, width: 48,
height: 48, height: 48,
@@ -324,6 +386,41 @@ const styles = StyleSheet.create({
color: '#1976D2', color: '#1976D2',
marginTop: 4, marginTop: 4,
}, },
pendingList: {
borderTopWidth: 1,
borderTopColor: '#f0f0f0',
paddingTop: 12,
gap: 8,
},
pendingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
pendingRowText: {
flex: 1,
fontSize: 13,
color: '#999',
},
pendingRowActive: {
color: '#1976D2',
fontWeight: '500',
},
stopBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#E53935',
borderRadius: 8,
paddingVertical: 10,
marginTop: 4,
},
stopBtnText: {
fontSize: 14,
color: '#fff',
fontWeight: '600',
},
fileInfo: { fileInfo: {
flex: 1, flex: 1,
}, },
+35 -4
View File
@@ -9,7 +9,15 @@ import { activeUploadUris } from '../services/uploadQueue';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError } from '../types'; import { ApiError } from '../types';
import { setIsSyncing } from './useSyncQueue'; import {
setIsSyncing,
setSyncProgress,
isSyncLoopRunning,
setSyncLoopRunning,
requestSyncCancel,
isSyncCancelRequested,
consumeSyncCancel,
} from './useSyncQueue';
const MAX_RETRIES = 5; const MAX_RETRIES = 5;
const RETRY_MMKV_ID = 'vaultdrop-sync-retries'; const RETRY_MMKV_ID = 'vaultdrop-sync-retries';
@@ -73,6 +81,8 @@ function canSyncBasedOnNetwork(netInfo: NetInfoState, cellularAllowed: boolean):
return true; return true;
} }
let autoSyncLoopStarted = false;
export function useAutoSync() { export function useAutoSync() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isRunning = useRef(false); const isRunning = useRef(false);
@@ -83,8 +93,13 @@ export function useAutoSync() {
); );
}, []); }, []);
const cancelSync = useCallback(() => {
requestSyncCancel();
}, []);
const runPendingSync = useCallback(async (pendingFiles: Array<ReturnType<typeof fileStore.getAllLocal>[number]>) => { const runPendingSync = useCallback(async (pendingFiles: Array<ReturnType<typeof fileStore.getAllLocal>[number]>) => {
if (isRunning.current) return; if (isRunning.current) return;
if (isSyncLoopRunning()) return;
const globalCellular = safDirectory.getGlobalSyncCellular(); const globalCellular = safDirectory.getGlobalSyncCellular();
const netInfo = await NetInfo.fetch(); const netInfo = await NetInfo.fetch();
@@ -92,11 +107,18 @@ export function useAutoSync() {
if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return; if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return;
isRunning.current = true; isRunning.current = true;
setSyncLoopRunning(true);
try { try {
setIsSyncing(true); setIsSyncing(true);
consumeSyncCancel();
for (const entry of pendingFiles) { const progressFiles = pendingFiles.map((f) => ({ id: f.id, name: f.name }));
for (let index = 0; index < pendingFiles.length; index++) {
if (isSyncCancelRequested()) break;
const entry = pendingFiles[index];
setSyncProgress({ files: progressFiles, currentIndex: index });
const uri = entry.localUri; const uri = entry.localUri;
if (!uri || activeUploadUris.has(uri)) continue; if (!uri || activeUploadUris.has(uri)) continue;
activeUploadUris.add(uri); activeUploadUris.add(uri);
@@ -126,10 +148,16 @@ export function useAutoSync() {
} }
} }
if (consumeSyncCancel()) {
return;
}
queryClient.invalidateQueries({ queryKey: ['resources'] }); queryClient.invalidateQueries({ queryKey: ['resources'] });
} finally { } finally {
setSyncProgress(null);
setIsSyncing(false); setIsSyncing(false);
isRunning.current = false; isRunning.current = false;
setSyncLoopRunning(false);
} }
}, [queryClient]); }, [queryClient]);
@@ -161,12 +189,15 @@ export function useAutoSync() {
}, [getPendingFiles, runPendingSync]); }, [getPendingFiles, runPendingSync]);
useEffect(() => { useEffect(() => {
if (autoSyncLoopStarted) return;
autoSyncLoopStarted = true;
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
checkAndSync(); checkAndSync();
}, 5_000); }, 5_000);
const interval = setInterval(checkAndSync, 30_000); const interval = setInterval(checkAndSync, 30_000);
return () => { clearTimeout(timeout); clearInterval(interval); }; return () => { clearTimeout(timeout); clearInterval(interval); autoSyncLoopStarted = false; };
}, [checkAndSync]); }, [checkAndSync]);
return { triggerSync: checkAndSync, syncManually }; return { triggerSync: checkAndSync, syncManually, cancelSync };
} }
+106 -13
View File
@@ -1,8 +1,22 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
import { createMMKV } from 'react-native-mmkv'; import { createMMKV } from 'react-native-mmkv';
import { fileStore } from '../services/fileStore'; import { fileStore } from '../services/fileStore';
export type SyncProgress = {
files: Array<{ id: string; name: string }>;
currentIndex: number;
} | null;
const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' }); const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' });
const PROGRESS_KEY = 'sync_progress';
let syncRunning = false;
let cancelRequested = false;
const listeners = new Set<() => void>();
function notify() {
listeners.forEach((l) => l());
}
export function getIsSyncing(): boolean { export function getIsSyncing(): boolean {
return syncStateStorage.getString('is_syncing') === 'true'; return syncStateStorage.getString('is_syncing') === 'true';
@@ -10,24 +24,98 @@ export function getIsSyncing(): boolean {
export function setIsSyncing(value: boolean) { export function setIsSyncing(value: boolean) {
syncStateStorage.set('is_syncing', value ? 'true' : 'false'); syncStateStorage.set('is_syncing', value ? 'true' : 'false');
notify();
}
export function getSyncProgress(): SyncProgress {
return parseProgress(syncStateStorage.getString(PROGRESS_KEY));
}
function parseProgress(raw: string | undefined): SyncProgress {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { files: Array<{ id: string; name: string }>; currentIndex: number };
if (!parsed || !Array.isArray(parsed.files)) return null;
return parsed;
} catch {
return null;
}
}
export function setSyncProgress(value: SyncProgress) {
if (value === null) {
syncStateStorage.remove(PROGRESS_KEY);
} else {
syncStateStorage.set(PROGRESS_KEY, JSON.stringify(value));
}
notify();
}
export function isSyncLoopRunning(): boolean {
return syncRunning;
}
export function setSyncLoopRunning(value: boolean) {
syncRunning = value;
}
export function requestSyncCancel() {
cancelRequested = true;
}
export function isSyncCancelRequested(): boolean {
return cancelRequested;
}
export function consumeSyncCancel(): boolean {
const value = cancelRequested;
cancelRequested = false;
return value;
}
export function resetSyncState() {
syncStateStorage.remove('is_syncing');
syncStateStorage.remove(PROGRESS_KEY);
syncRunning = false;
cancelRequested = false;
notify();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
let cachedIsSyncing = getIsSyncing();
let lastIsSyncingRaw: string | undefined;
function getIsSyncingSnapshot(): boolean {
const raw = syncStateStorage.getString('is_syncing');
if (raw !== lastIsSyncingRaw) {
lastIsSyncingRaw = raw;
cachedIsSyncing = raw === 'true';
}
return cachedIsSyncing;
}
let cachedProgress = getSyncProgress();
let lastProgressRaw: string | undefined;
function getSyncProgressSnapshot(): SyncProgress {
const raw = syncStateStorage.getString(PROGRESS_KEY);
if (raw !== lastProgressRaw) {
lastProgressRaw = raw;
cachedProgress = parseProgress(raw);
}
return cachedProgress;
} }
export function useSyncQueue() { export function useSyncQueue() {
const isSyncing = useSyncExternalStore(subscribe, getIsSyncingSnapshot, getIsSyncingSnapshot);
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);
const [isSyncing, setIsSyncingState] = useState(() => getIsSyncing());
const refresh = useCallback(() => { const refresh = useCallback(() => {
const registry = fileStore.getAllLocal(); setPendingCount(fileStore.countPendingSync());
let count = 0;
for (const entry of registry) {
if (entry.backendId || !entry.localUri) continue;
if (entry.syncStatus !== 'local' && entry.syncStatus !== 'error') continue;
count++;
}
setPendingCount(count);
setIsSyncingState(getIsSyncing());
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -38,3 +126,8 @@ export function useSyncQueue() {
return { pendingCount, isSyncing, refresh }; return { pendingCount, isSyncing, refresh };
} }
export function useSyncProgress() {
const syncProgress = useSyncExternalStore(subscribe, getSyncProgressSnapshot, getSyncProgressSnapshot);
return { syncProgress };
}
+14 -1
View File
@@ -1,6 +1,6 @@
import { drizzle } from 'drizzle-orm/expo-sqlite'; import { drizzle } from 'drizzle-orm/expo-sqlite';
import * as SQLite from 'expo-sqlite'; import * as SQLite from 'expo-sqlite';
import { eq, like, or, and, desc, asc, sql, isNull } from 'drizzle-orm'; import { eq, like, or, and, desc, asc, sql, isNull, inArray, isNotNull } from 'drizzle-orm';
import { files, fileTags, deletedFiles, pendingActions } from './schema'; import { files, fileTags, deletedFiles, pendingActions } from './schema';
import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from '../../types'; import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from '../../types';
@@ -590,6 +590,19 @@ export const fileStore = {
return row?.count ?? 0; return row?.count ?? 0;
}, },
countPendingSync(): number {
const d = getDb();
const row = d.select({ count: sql<number>`count(*)` }).from(files).where(
and(
or(eq(files.source, 'local'), eq(files.source, 'synced')),
isNull(files.backendId),
isNotNull(files.localUri),
inArray(files.syncStatus, ['local', 'error']),
)
).get();
return row?.count ?? 0;
},
getAllLocal(): FileRecord[] { getAllLocal(): FileRecord[] {
const d = getDb(); const d = getDb();
const rows = d.select().from(files) const rows = d.select().from(files)