| 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<string[]> => { |
| 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<string[]> => { |
| const next = [...list, id]; |
| try { |
| await AsyncStorage.setItem(SYNCED_ASSETS_KEY, JSON.stringify(next)); |
| } catch {} |
| return next; |
| }; |
|
|
| |
| |
| |
| const uploadAsset = async (asset: MediaLibrary.Asset): Promise<boolean> => { |
| 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; |
| } |
| }; |
|
|
| |
| |
| |
| export const runSyncCycle = async (callbacks?: SyncCallbacks) => { |
| |
| const netInfo = await Network.getNetworkStateAsync(); |
| if (!netInfo.isConnected) { |
| callbacks?.onInterrupted?.('Offline'); |
| return false; |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| let media: MediaLibrary.PagedInfo<MediaLibrary.Asset>; |
| try { |
| |
| 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; |
| } |
| media = { assets: localAssets } as any; |
| } catch (e) { |
| callbacks?.onInterrupted?.('Media Library Error'); |
| return false; |
| } |
|
|
| |
| let synced = await getSyncedList(); |
| const toSync = media.assets.filter(a => !synced.includes(a.id)); |
|
|
| if (!toSync.length) { |
| callbacks?.onStatusChange?.('done', 0); |
| return true; |
| } |
|
|
| callbacks?.onStatusChange?.('syncing', toSync.length); |
| let ok = 0; |
|
|
| |
| 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<boolean> => { |
| 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<boolean> => { |
| 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; |
| } |
| }; |
|
|
| |
| 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; |
| } |
| }); |
|
|