qd
Browse files- keystone-app/app/(tabs)/index.tsx +25 -61
- keystone-app/app/(tabs)/settings.tsx +44 -3
- keystone-app/app/_layout.tsx +21 -0
- keystone-app/app/utils/syncManager.ts +139 -0
- keystone-app/package-lock.json +33 -0
- keystone-app/package.json +2 -0
keystone-app/app/(tabs)/index.tsx
CHANGED
|
@@ -26,9 +26,8 @@ import { format, isToday, isYesterday } from 'date-fns';
|
|
| 26 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 27 |
|
| 28 |
import { SYNC_ON_CELLULAR_KEY } from './settings';
|
|
|
|
| 29 |
|
| 30 |
-
const BACKEND_URL = 'https://dpv007-keystone.hf.space';
|
| 31 |
-
const MOCK_TOKEN = 'mock-jwt-token-xyz123';
|
| 32 |
const SYNCED_ASSETS_KEY = '@synced_assets_v2';
|
| 33 |
|
| 34 |
const { width, height } = Dimensions.get('window');
|
|
@@ -182,72 +181,37 @@ export default function PhotosScreen() {
|
|
| 182 |
} catch { return []; }
|
| 183 |
};
|
| 184 |
|
| 185 |
-
const addSyncedId = async (id: string, list: string[]): Promise<string[]> => {
|
| 186 |
-
const next = [...list, id];
|
| 187 |
-
try { await AsyncStorage.setItem(SYNCED_ASSETS_KEY, JSON.stringify(next)); } catch { }
|
| 188 |
-
return next;
|
| 189 |
-
};
|
| 190 |
-
|
| 191 |
const syncAssets = async () => {
|
| 192 |
-
if (
|
| 193 |
|
| 194 |
-
if (!netInfo?.isConnected) {
|
| 195 |
-
Alert.alert('Offline', 'Connect to the internet to backup your photos.');
|
| 196 |
-
return;
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
if (netInfo?.type === Network.NetworkStateType.CELLULAR && !allowCellular) {
|
| 200 |
-
Alert.alert('Waiting for Wi-Fi', 'Syncing on mobile data is disabled in Settings.');
|
| 201 |
-
return;
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
setIsSyncing(true);
|
| 205 |
setSyncStatus('syncing');
|
| 206 |
-
|
| 207 |
-
const toSync = allAssets.filter(a => !synced.includes(a.id));
|
| 208 |
-
|
| 209 |
-
if (!toSync.length) {
|
| 210 |
-
setIsSyncing(false);
|
| 211 |
-
setSyncStatus('done');
|
| 212 |
-
setItemsToBackup(0);
|
| 213 |
-
setTimeout(() => setSyncStatus('idle'), 3000);
|
| 214 |
-
return;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
setSyncTotal(toSync.length);
|
| 218 |
setSyncProgress(0);
|
| 219 |
-
let ok = 0;
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
break;
|
| 244 |
}
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
setIsSyncing(false);
|
| 248 |
-
setSyncStatus(ok === toSync.length ? 'done' : 'idle');
|
| 249 |
-
if (ok === toSync.length) {
|
| 250 |
-
setTimeout(() => setSyncStatus('idle'), 4000);
|
| 251 |
}
|
| 252 |
};
|
| 253 |
|
|
|
|
| 26 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 27 |
|
| 28 |
import { SYNC_ON_CELLULAR_KEY } from './settings';
|
| 29 |
+
import { runSyncCycle } from '../utils/syncManager';
|
| 30 |
|
|
|
|
|
|
|
| 31 |
const SYNCED_ASSETS_KEY = '@synced_assets_v2';
|
| 32 |
|
| 33 |
const { width, height } = Dimensions.get('window');
|
|
|
|
| 181 |
} catch { return []; }
|
| 182 |
};
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
const syncAssets = async () => {
|
| 185 |
+
if (isSyncing || itemsToBackup === 0) return;
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
setIsSyncing(true);
|
| 188 |
setSyncStatus('syncing');
|
| 189 |
+
setSyncTotal(itemsToBackup);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
setSyncProgress(0);
|
|
|
|
| 191 |
|
| 192 |
+
const success = await runSyncCycle({
|
| 193 |
+
onProgress: (current, total) => {
|
| 194 |
+
setSyncProgress(current);
|
| 195 |
+
},
|
| 196 |
+
onStatusChange: (status, itemsLeft) => {
|
| 197 |
+
setSyncStatus(status);
|
| 198 |
+
setItemsToBackup(itemsLeft);
|
| 199 |
+
},
|
| 200 |
+
onInterrupted: (reason) => {
|
| 201 |
+
Alert.alert('Sync Interrupted', reason);
|
| 202 |
+
setIsSyncing(false);
|
| 203 |
+
setSyncStatus('idle');
|
| 204 |
+
}
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
if (success) {
|
| 208 |
+
setIsSyncing(false);
|
| 209 |
+
if (itemsToBackup === 0) {
|
| 210 |
+
setSyncStatus('done');
|
| 211 |
+
setTimeout(() => setSyncStatus('idle'), 4000);
|
| 212 |
+
} else {
|
| 213 |
+
setSyncStatus('idle');
|
|
|
|
| 214 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
};
|
| 217 |
|
keystone-app/app/(tabs)/settings.tsx
CHANGED
|
@@ -4,11 +4,13 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
|
| 4 |
import { StatusBar } from 'expo-status-bar';
|
| 5 |
import { MaterialIcons } from '@expo/vector-icons';
|
| 6 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
|
|
| 7 |
|
| 8 |
export const SYNC_ON_CELLULAR_KEY = '@setting_sync_cellular';
|
| 9 |
|
| 10 |
export default function SettingsScreen() {
|
| 11 |
const [syncOnCellular, setSyncOnCellular] = useState(false);
|
|
|
|
| 12 |
const [isLoading, setIsLoading] = useState(true);
|
| 13 |
|
| 14 |
useEffect(() => {
|
|
@@ -17,9 +19,18 @@ export default function SettingsScreen() {
|
|
| 17 |
|
| 18 |
const loadSettings = async () => {
|
| 19 |
try {
|
| 20 |
-
const
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
} catch (e) {
|
| 25 |
console.error('Failed to load settings', e);
|
|
@@ -37,6 +48,15 @@ export default function SettingsScreen() {
|
|
| 37 |
}
|
| 38 |
};
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
if (Platform.OS === 'web') {
|
| 41 |
return (
|
| 42 |
<View style={styles.center}>
|
|
@@ -58,6 +78,26 @@ export default function SettingsScreen() {
|
|
| 58 |
<View style={styles.settingsList}>
|
| 59 |
<Text style={styles.sectionHeader}>Sync & Backup</Text>
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<View style={styles.settingItem}>
|
| 62 |
<View style={styles.settingLeft}>
|
| 63 |
<View style={styles.iconBox}>
|
|
@@ -94,6 +134,7 @@ const styles = StyleSheet.create({
|
|
| 94 |
sectionHeader: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1, color: '#5f6368', paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 },
|
| 95 |
|
| 96 |
settingItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 16 },
|
|
|
|
| 97 |
settingLeft: { flexDirection: 'row', alignItems: 'center', flex: 1, paddingRight: 16 },
|
| 98 |
iconBox: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#e8f0fe', justifyContent: 'center', alignItems: 'center', marginRight: 16 },
|
| 99 |
settingLabel: { fontSize: 16, fontWeight: '600', color: '#202124' },
|
|
|
|
| 4 |
import { StatusBar } from 'expo-status-bar';
|
| 5 |
import { MaterialIcons } from '@expo/vector-icons';
|
| 6 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 7 |
+
import { BACKGROUND_SYNC_ENABLED_KEY } from '../utils/syncManager';
|
| 8 |
|
| 9 |
export const SYNC_ON_CELLULAR_KEY = '@setting_sync_cellular';
|
| 10 |
|
| 11 |
export default function SettingsScreen() {
|
| 12 |
const [syncOnCellular, setSyncOnCellular] = useState(false);
|
| 13 |
+
const [bgSync, setBgSync] = useState(true); // Default to true if not set
|
| 14 |
const [isLoading, setIsLoading] = useState(true);
|
| 15 |
|
| 16 |
useEffect(() => {
|
|
|
|
| 19 |
|
| 20 |
const loadSettings = async () => {
|
| 21 |
try {
|
| 22 |
+
const [cellVal, bgVal] = await Promise.all([
|
| 23 |
+
AsyncStorage.getItem(SYNC_ON_CELLULAR_KEY),
|
| 24 |
+
AsyncStorage.getItem(BACKGROUND_SYNC_ENABLED_KEY)
|
| 25 |
+
]);
|
| 26 |
+
|
| 27 |
+
if (cellVal !== null) setSyncOnCellular(cellVal === 'true');
|
| 28 |
+
|
| 29 |
+
// If background sync is not set, default to true
|
| 30 |
+
if (bgVal !== null) {
|
| 31 |
+
setBgSync(bgVal === 'true');
|
| 32 |
+
} else {
|
| 33 |
+
await AsyncStorage.setItem(BACKGROUND_SYNC_ENABLED_KEY, 'true');
|
| 34 |
}
|
| 35 |
} catch (e) {
|
| 36 |
console.error('Failed to load settings', e);
|
|
|
|
| 48 |
}
|
| 49 |
};
|
| 50 |
|
| 51 |
+
const toggleBgSync = async (value: boolean) => {
|
| 52 |
+
setBgSync(value);
|
| 53 |
+
try {
|
| 54 |
+
await AsyncStorage.setItem(BACKGROUND_SYNC_ENABLED_KEY, value.toString());
|
| 55 |
+
} catch (e) {
|
| 56 |
+
console.error('Failed to save setting', e);
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
if (Platform.OS === 'web') {
|
| 61 |
return (
|
| 62 |
<View style={styles.center}>
|
|
|
|
| 78 |
<View style={styles.settingsList}>
|
| 79 |
<Text style={styles.sectionHeader}>Sync & Backup</Text>
|
| 80 |
|
| 81 |
+
<View style={styles.settingItem}>
|
| 82 |
+
<View style={styles.settingLeft}>
|
| 83 |
+
<View style={styles.iconBox}>
|
| 84 |
+
<MaterialIcons name="cloud-sync" size={24} color="#1a73e8" />
|
| 85 |
+
</View>
|
| 86 |
+
<View>
|
| 87 |
+
<Text style={styles.settingLabel}>Background Backup</Text>
|
| 88 |
+
<Text style={styles.settingDesc}>Upload photos quietly while the app is closed.</Text>
|
| 89 |
+
</View>
|
| 90 |
+
</View>
|
| 91 |
+
<Switch
|
| 92 |
+
trackColor={{ false: '#d1d1d6', true: '#1a73e8' }}
|
| 93 |
+
thumbColor={Platform.OS === 'ios' ? '#fff' : bgSync ? '#fff' : '#f4f3f4'}
|
| 94 |
+
onValueChange={toggleBgSync}
|
| 95 |
+
value={bgSync}
|
| 96 |
+
/>
|
| 97 |
+
</View>
|
| 98 |
+
|
| 99 |
+
<View style={styles.settingDivider} />
|
| 100 |
+
|
| 101 |
<View style={styles.settingItem}>
|
| 102 |
<View style={styles.settingLeft}>
|
| 103 |
<View style={styles.iconBox}>
|
|
|
|
| 134 |
sectionHeader: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1, color: '#5f6368', paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8 },
|
| 135 |
|
| 136 |
settingItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 16 },
|
| 137 |
+
settingDivider: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 72 },
|
| 138 |
settingLeft: { flexDirection: 'row', alignItems: 'center', flex: 1, paddingRight: 16 },
|
| 139 |
iconBox: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#e8f0fe', justifyContent: 'center', alignItems: 'center', marginRight: 16 },
|
| 140 |
settingLabel: { fontSize: 16, fontWeight: '600', color: '#202124' },
|
keystone-app/app/_layout.tsx
CHANGED
|
@@ -1,7 +1,28 @@
|
|
| 1 |
import { Stack } from 'expo-router';
|
| 2 |
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export default function Layout() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
return (
|
| 6 |
<SafeAreaProvider>
|
| 7 |
<Stack>
|
|
|
|
| 1 |
import { Stack } from 'expo-router';
|
| 2 |
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
| 3 |
+
import * as BackgroundFetch from 'expo-background-fetch';
|
| 4 |
+
import { useEffect } from 'react';
|
| 5 |
+
|
| 6 |
+
// Import syncManager to ensure TaskManager.defineTask is called in the global scope
|
| 7 |
+
import { BACKGROUND_SYNC_TASK } from './utils/syncManager';
|
| 8 |
|
| 9 |
export default function Layout() {
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
// Register the background fetch task with the OS
|
| 12 |
+
const registerTask = async () => {
|
| 13 |
+
try {
|
| 14 |
+
await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
|
| 15 |
+
minimumInterval: 60 * 15, // 15 minutes
|
| 16 |
+
stopOnTerminate: false, // Android only
|
| 17 |
+
startOnBoot: true, // Android only
|
| 18 |
+
});
|
| 19 |
+
} catch (err) {
|
| 20 |
+
console.warn('Background fetch failed to register:', err);
|
| 21 |
+
}
|
| 22 |
+
};
|
| 23 |
+
registerTask();
|
| 24 |
+
}, []);
|
| 25 |
+
|
| 26 |
return (
|
| 27 |
<SafeAreaProvider>
|
| 28 |
<Stack>
|
keystone-app/app/utils/syncManager.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as TaskManager from 'expo-task-manager';
|
| 2 |
+
import * as BackgroundFetch from 'expo-background-fetch';
|
| 3 |
+
import * as MediaLibrary from 'expo-media-library';
|
| 4 |
+
import * as FileSystem from 'expo-file-system/legacy';
|
| 5 |
+
import * as Network from 'expo-network';
|
| 6 |
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 7 |
+
import { SYNC_ON_CELLULAR_KEY } from '../(tabs)/settings';
|
| 8 |
+
|
| 9 |
+
export const BACKGROUND_SYNC_TASK = 'BACKGROUND_SYNC_TASK';
|
| 10 |
+
export const BACKGROUND_SYNC_ENABLED_KEY = '@setting_background_sync';
|
| 11 |
+
|
| 12 |
+
const BACKEND_URL = 'https://dpv007-keystone.hf.space';
|
| 13 |
+
const MOCK_TOKEN = 'mock-jwt-token-xyz123';
|
| 14 |
+
const SYNCED_ASSETS_KEY = '@synced_assets_v2';
|
| 15 |
+
|
| 16 |
+
export type SyncCallbacks = {
|
| 17 |
+
onProgress?: (current: number, total: number) => void;
|
| 18 |
+
onStatusChange?: (status: 'idle' | 'syncing' | 'done', itemsLeft: number) => void;
|
| 19 |
+
onInterrupted?: (reason: string) => void;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const getSyncedList = async (): Promise<string[]> => {
|
| 23 |
+
try {
|
| 24 |
+
const v = await AsyncStorage.getItem(SYNCED_ASSETS_KEY);
|
| 25 |
+
return v ? JSON.parse(v) : [];
|
| 26 |
+
} catch {
|
| 27 |
+
return [];
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const addSyncedId = async (id: string, list: string[]): Promise<string[]> => {
|
| 32 |
+
const next = [...list, id];
|
| 33 |
+
try {
|
| 34 |
+
await AsyncStorage.setItem(SYNCED_ASSETS_KEY, JSON.stringify(next));
|
| 35 |
+
} catch {}
|
| 36 |
+
return next;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Core sync logic used by both foreground UI and background fetch.
|
| 41 |
+
*/
|
| 42 |
+
export const runSyncCycle = async (callbacks?: SyncCallbacks) => {
|
| 43 |
+
// 1. Check network
|
| 44 |
+
const netInfo = await Network.getNetworkStateAsync();
|
| 45 |
+
if (!netInfo.isConnected) {
|
| 46 |
+
callbacks?.onInterrupted?.('Offline');
|
| 47 |
+
return false;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// 2. Check cellular settings
|
| 51 |
+
if (netInfo.type === Network.NetworkStateType.CELLULAR) {
|
| 52 |
+
const syncOnCellular = await AsyncStorage.getItem(SYNC_ON_CELLULAR_KEY);
|
| 53 |
+
if (syncOnCellular !== 'true') {
|
| 54 |
+
callbacks?.onInterrupted?.('Waiting for Wi-Fi');
|
| 55 |
+
return false;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// 3. Fetch assets
|
| 60 |
+
let media: MediaLibrary.PagedInfo<MediaLibrary.Asset>;
|
| 61 |
+
try {
|
| 62 |
+
// For background sync, we might not have permissions (though usually we do if UI was opened)
|
| 63 |
+
const perms = await MediaLibrary.getPermissionsAsync();
|
| 64 |
+
if (!perms.granted) {
|
| 65 |
+
callbacks?.onInterrupted?.('No permissions');
|
| 66 |
+
return false;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
media = await MediaLibrary.getAssetsAsync({
|
| 70 |
+
first: 800,
|
| 71 |
+
sortBy: [MediaLibrary.SortBy.creationTime],
|
| 72 |
+
mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
|
| 73 |
+
});
|
| 74 |
+
} catch (e) {
|
| 75 |
+
callbacks?.onInterrupted?.('Media Library Error');
|
| 76 |
+
return false;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// 4. Determine what needs syncing
|
| 80 |
+
let synced = await getSyncedList();
|
| 81 |
+
const toSync = media.assets.filter(a => !synced.includes(a.id));
|
| 82 |
+
|
| 83 |
+
if (!toSync.length) {
|
| 84 |
+
callbacks?.onStatusChange?.('done', 0);
|
| 85 |
+
return true; // Nothing to sync
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
callbacks?.onStatusChange?.('syncing', toSync.length);
|
| 89 |
+
let ok = 0;
|
| 90 |
+
|
| 91 |
+
// 5. Upload loop
|
| 92 |
+
for (let i = 0; i < toSync.length; i++) {
|
| 93 |
+
callbacks?.onProgress?.(i + 1, toSync.length);
|
| 94 |
+
const asset = toSync[i];
|
| 95 |
+
try {
|
| 96 |
+
const info = await MediaLibrary.getAssetInfoAsync(asset);
|
| 97 |
+
const localUri = info.localUri || asset.uri;
|
| 98 |
+
|
| 99 |
+
const resp = await FileSystem.uploadAsync(`${BACKEND_URL}/upload?token=${MOCK_TOKEN}`, localUri, {
|
| 100 |
+
fieldName: 'file',
|
| 101 |
+
httpMethod: 'POST',
|
| 102 |
+
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
|
| 103 |
+
parameters: { creation_time: asset.creationTime.toString() },
|
| 104 |
+
headers: { authorization: `Bearer ${MOCK_TOKEN}` },
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
if (resp.status === 200 || resp.status === 201) {
|
| 108 |
+
synced = await addSyncedId(asset.id, synced);
|
| 109 |
+
ok++;
|
| 110 |
+
callbacks?.onStatusChange?.('syncing', toSync.length - ok);
|
| 111 |
+
}
|
| 112 |
+
} catch (e) {
|
| 113 |
+
console.error('Upload error', e);
|
| 114 |
+
callbacks?.onInterrupted?.('Sync Interrupted');
|
| 115 |
+
return false; // Stop sync on failure to preserve battery/retries
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
callbacks?.onStatusChange?.('done', 0);
|
| 120 |
+
return true;
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
// βββ Background Task Definition βββββββββββββββββββββββββββββββββββββ
|
| 124 |
+
TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
|
| 125 |
+
try {
|
| 126 |
+
const isBgSyncEnabled = await AsyncStorage.getItem(BACKGROUND_SYNC_ENABLED_KEY);
|
| 127 |
+
if (isBgSyncEnabled !== 'true') {
|
| 128 |
+
return BackgroundFetch.BackgroundFetchResult.NoData;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const success = await runSyncCycle();
|
| 132 |
+
return success
|
| 133 |
+
? BackgroundFetch.BackgroundFetchResult.NewData
|
| 134 |
+
: BackgroundFetch.BackgroundFetchResult.Failed;
|
| 135 |
+
} catch (error) {
|
| 136 |
+
console.error('Background sync failed:', error);
|
| 137 |
+
return BackgroundFetch.BackgroundFetchResult.Failed;
|
| 138 |
+
}
|
| 139 |
+
});
|
keystone-app/package-lock.json
CHANGED
|
@@ -14,6 +14,7 @@
|
|
| 14 |
"date-fns": "^4.4.0",
|
| 15 |
"expo": "^54.0.36",
|
| 16 |
"expo-av": "~16.0.8",
|
|
|
|
| 17 |
"expo-dev-client": "~6.0.21",
|
| 18 |
"expo-file-system": "~19.0.23",
|
| 19 |
"expo-font": "~14.0.12",
|
|
@@ -24,6 +25,7 @@
|
|
| 24 |
"expo-router": "~6.0.24",
|
| 25 |
"expo-sharing": "~14.0.8",
|
| 26 |
"expo-status-bar": "~3.0.9",
|
|
|
|
| 27 |
"react": "19.1.0",
|
| 28 |
"react-dom": "19.1.0",
|
| 29 |
"react-native": "0.81.5",
|
|
@@ -4512,6 +4514,18 @@
|
|
| 4512 |
}
|
| 4513 |
}
|
| 4514 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4515 |
"node_modules/expo-constants": {
|
| 4516 |
"version": "18.0.13",
|
| 4517 |
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
|
@@ -5056,6 +5070,19 @@
|
|
| 5056 |
"react-native": "*"
|
| 5057 |
}
|
| 5058 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5059 |
"node_modules/expo-updates-interface": {
|
| 5060 |
"version": "2.0.0",
|
| 5061 |
"license": "MIT",
|
|
@@ -8929,6 +8956,12 @@
|
|
| 8929 |
"node": ">=4"
|
| 8930 |
}
|
| 8931 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8932 |
"node_modules/unpipe": {
|
| 8933 |
"version": "1.0.0",
|
| 8934 |
"license": "MIT",
|
|
|
|
| 14 |
"date-fns": "^4.4.0",
|
| 15 |
"expo": "^54.0.36",
|
| 16 |
"expo-av": "~16.0.8",
|
| 17 |
+
"expo-background-fetch": "~14.0.9",
|
| 18 |
"expo-dev-client": "~6.0.21",
|
| 19 |
"expo-file-system": "~19.0.23",
|
| 20 |
"expo-font": "~14.0.12",
|
|
|
|
| 25 |
"expo-router": "~6.0.24",
|
| 26 |
"expo-sharing": "~14.0.8",
|
| 27 |
"expo-status-bar": "~3.0.9",
|
| 28 |
+
"expo-task-manager": "~14.0.9",
|
| 29 |
"react": "19.1.0",
|
| 30 |
"react-dom": "19.1.0",
|
| 31 |
"react-native": "0.81.5",
|
|
|
|
| 4514 |
}
|
| 4515 |
}
|
| 4516 |
},
|
| 4517 |
+
"node_modules/expo-background-fetch": {
|
| 4518 |
+
"version": "14.0.9",
|
| 4519 |
+
"resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-14.0.9.tgz",
|
| 4520 |
+
"integrity": "sha512-IhdbjIu9EdsYaL7mCCvf/i48Qy4a5rpRy038/4KNUoa9xmsETRwFCdsoZj4VHg4dVt2D0kiDrgqVVlPBSSWt+Q==",
|
| 4521 |
+
"license": "MIT",
|
| 4522 |
+
"dependencies": {
|
| 4523 |
+
"expo-task-manager": "~14.0.9"
|
| 4524 |
+
},
|
| 4525 |
+
"peerDependencies": {
|
| 4526 |
+
"expo": "*"
|
| 4527 |
+
}
|
| 4528 |
+
},
|
| 4529 |
"node_modules/expo-constants": {
|
| 4530 |
"version": "18.0.13",
|
| 4531 |
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
|
|
|
| 5070 |
"react-native": "*"
|
| 5071 |
}
|
| 5072 |
},
|
| 5073 |
+
"node_modules/expo-task-manager": {
|
| 5074 |
+
"version": "14.0.9",
|
| 5075 |
+
"resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-14.0.9.tgz",
|
| 5076 |
+
"integrity": "sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA==",
|
| 5077 |
+
"license": "MIT",
|
| 5078 |
+
"dependencies": {
|
| 5079 |
+
"unimodules-app-loader": "~6.0.8"
|
| 5080 |
+
},
|
| 5081 |
+
"peerDependencies": {
|
| 5082 |
+
"expo": "*",
|
| 5083 |
+
"react-native": "*"
|
| 5084 |
+
}
|
| 5085 |
+
},
|
| 5086 |
"node_modules/expo-updates-interface": {
|
| 5087 |
"version": "2.0.0",
|
| 5088 |
"license": "MIT",
|
|
|
|
| 8956 |
"node": ">=4"
|
| 8957 |
}
|
| 8958 |
},
|
| 8959 |
+
"node_modules/unimodules-app-loader": {
|
| 8960 |
+
"version": "6.0.8",
|
| 8961 |
+
"resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz",
|
| 8962 |
+
"integrity": "sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA==",
|
| 8963 |
+
"license": "MIT"
|
| 8964 |
+
},
|
| 8965 |
"node_modules/unpipe": {
|
| 8966 |
"version": "1.0.0",
|
| 8967 |
"license": "MIT",
|
keystone-app/package.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
| 9 |
"date-fns": "^4.4.0",
|
| 10 |
"expo": "^54.0.36",
|
| 11 |
"expo-av": "~16.0.8",
|
|
|
|
| 12 |
"expo-dev-client": "~6.0.21",
|
| 13 |
"expo-file-system": "~19.0.23",
|
| 14 |
"expo-font": "~14.0.12",
|
|
@@ -19,6 +20,7 @@
|
|
| 19 |
"expo-router": "~6.0.24",
|
| 20 |
"expo-sharing": "~14.0.8",
|
| 21 |
"expo-status-bar": "~3.0.9",
|
|
|
|
| 22 |
"react": "19.1.0",
|
| 23 |
"react-dom": "19.1.0",
|
| 24 |
"react-native": "0.81.5",
|
|
|
|
| 9 |
"date-fns": "^4.4.0",
|
| 10 |
"expo": "^54.0.36",
|
| 11 |
"expo-av": "~16.0.8",
|
| 12 |
+
"expo-background-fetch": "~14.0.9",
|
| 13 |
"expo-dev-client": "~6.0.21",
|
| 14 |
"expo-file-system": "~19.0.23",
|
| 15 |
"expo-font": "~14.0.12",
|
|
|
|
| 20 |
"expo-router": "~6.0.24",
|
| 21 |
"expo-sharing": "~14.0.8",
|
| 22 |
"expo-status-bar": "~3.0.9",
|
| 23 |
+
"expo-task-manager": "~14.0.9",
|
| 24 |
"react": "19.1.0",
|
| 25 |
"react-dom": "19.1.0",
|
| 26 |
"react-native": "0.81.5",
|