import * as TaskManager from 'expo-task-manager'; import * as BackgroundFetch from 'expo-background-fetch'; import * as MediaLibrary from 'expo-media-library'; import * as FileSystem from 'expo-file-system/legacy'; import * as Network from 'expo-network'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { SYNC_ON_CELLULAR_KEY } from '../(tabs)/settings'; export const BACKGROUND_SYNC_TASK = 'BACKGROUND_SYNC_TASK'; export const BACKGROUND_SYNC_ENABLED_KEY = '@setting_background_sync'; export const BACKEND_URL = 'https://dpv007-keystone.hf.space'; export const getToken = async () => { return await AsyncStorage.getItem('jwt_token'); }; export const SYNCED_ASSETS_KEY = '@synced_assets_v2'; export type SyncCallbacks = { onProgress?: (current: number, total: number) => void; onStatusChange?: (status: 'idle' | 'syncing' | 'done', itemsLeft: number) => void; onInterrupted?: (reason: string) => void; }; export const getSyncedList = async (): Promise => { try { const v = await AsyncStorage.getItem(SYNCED_ASSETS_KEY); return v ? JSON.parse(v) : []; } catch { return []; } }; export const addSyncedId = async (id: string, list: string[]): Promise => { const next = [...list, id]; try { await AsyncStorage.setItem(SYNCED_ASSETS_KEY, JSON.stringify(next)); } catch {} return next; }; /** * Upload a single asset to the backend using the stored JWT. */ const uploadAsset = async (asset: MediaLibrary.Asset): Promise => { try { const token = await getToken(); if (!token) return false; const info = await MediaLibrary.getAssetInfoAsync(asset); const uri = info.localUri || asset.uri; const res = await FileSystem.uploadAsync(`${BACKEND_URL}/upload`, uri, { httpMethod: 'POST', uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'file', parameters: { creation_time: asset.creationTime.toString(), token: token, }, }); return res.status === 200 || res.status === 201; } catch (error) { console.error(`Error uploading asset ${asset.id}:`, error); return false; } }; /** * Core sync logic used by both foreground UI and background fetch. */ export const runSyncCycle = async (callbacks?: SyncCallbacks) => { // 1. Check network const netInfo = await Network.getNetworkStateAsync(); if (!netInfo.isConnected) { callbacks?.onInterrupted?.('Offline'); return false; } // 2. Check cellular settings if (netInfo.type === Network.NetworkStateType.CELLULAR) { const syncOnCellular = await AsyncStorage.getItem(SYNC_ON_CELLULAR_KEY); if (syncOnCellular !== 'true') { callbacks?.onInterrupted?.('Waiting for Wi-Fi'); return false; } } // 3. Fetch assets let media: MediaLibrary.PagedInfo; try { // For background sync, we might not have permissions (though usually we do if UI was opened) const perms = await MediaLibrary.getPermissionsAsync(); if (!perms.granted) { callbacks?.onInterrupted?.('No permissions'); return false; } let localAssets: MediaLibrary.Asset[] = []; let hasNextPage = true; let after: string | undefined = undefined; while (hasNextPage) { const mediaPage = await MediaLibrary.getAssetsAsync({ first: 1500, after, sortBy: [MediaLibrary.SortBy.creationTime], mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video], }); localAssets.push(...mediaPage.assets); hasNextPage = mediaPage.hasNextPage; after = mediaPage.endCursor; if (localAssets.length >= 10000) break; // Safe limit } media = { assets: localAssets } as any; } catch (e) { callbacks?.onInterrupted?.('Media Library Error'); return false; } // 4. Determine what needs syncing let synced = await getSyncedList(); const toSync = media.assets.filter(a => !synced.includes(a.id)); if (!toSync.length) { callbacks?.onStatusChange?.('done', 0); return true; // Nothing to sync } callbacks?.onStatusChange?.('syncing', toSync.length); let ok = 0; // 5. Upload loop for (let i = 0; i < toSync.length; i++) { callbacks?.onProgress?.(i + 1, toSync.length); const asset = toSync[i]; const success = await uploadAsset(asset); if (success) { synced = await addSyncedId(asset.id, synced); ok++; callbacks?.onStatusChange?.('syncing', toSync.length - ok); } else { callbacks?.onInterrupted?.('Sync Interrupted'); return false; } } callbacks?.onStatusChange?.('done', 0); return true; }; export const deleteFromCloud = async (cloudId: string): Promise => { try { const token = await getToken(); if (!token) return false; const res = await fetch(`${BACKEND_URL}/photos/delete/${cloudId}?token=${token}`, { method: 'DELETE' }); return res.ok; } catch (e) { console.error('Failed to delete from cloud', e); return false; } }; export const downloadToDevice = async (cloudUri: string, filename: string): Promise => { try { const fileUri = `${FileSystem.documentDirectory}${filename}`; const { uri } = await FileSystem.downloadAsync(cloudUri, fileUri); const asset = await MediaLibrary.createAssetAsync(uri); return !!asset; } catch (e) { console.error('Failed to download to device', e); return false; } }; // ─── Background Task Definition ───────────────────────────────────── TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => { try { const isBgSyncEnabled = await AsyncStorage.getItem(BACKGROUND_SYNC_ENABLED_KEY); if (isBgSyncEnabled !== 'true') { return BackgroundFetch.BackgroundFetchResult.NoData; } const success = await runSyncCycle(); return success ? BackgroundFetch.BackgroundFetchResult.NewData : BackgroundFetch.BackgroundFetchResult.Failed; } catch (error) { console.error('Background sync failed:', error); return BackgroundFetch.BackgroundFetchResult.Failed; } });