KeyStone / keystone-app /app /utils /syncManager.ts
dpv007's picture
Fix storage stats bar and increase MediaLibrary photo fetch limit for WhatsApp
b5010b0
Raw
History Blame Contribute Delete
6.2 kB
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;
};
/**
* Upload a single asset to the backend using the stored JWT.
*/
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;
}
};
/**
* 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<MediaLibrary.Asset>;
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<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;
}
};
// ─── 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;
}
});