dpv007 commited on
Commit
e0405f2
Β·
1 Parent(s): 6054a2e
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 (allAssets.length === 0 || isSyncing) return;
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
- let synced = await getSyncedList();
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
- for (let i = 0; i < toSync.length; i++) {
222
- setSyncProgress(i + 1);
223
- const asset = toSync[i];
224
- try {
225
- const info = await MediaLibrary.getAssetInfoAsync(asset);
226
- const localUri = info.localUri || asset.uri;
227
- const resp = await FileSystem.uploadAsync(`${BACKEND_URL}/upload?token=${MOCK_TOKEN}`, localUri, {
228
- fieldName: 'file',
229
- httpMethod: 'POST',
230
- uploadType: FileSystem.FileSystemUploadType.MULTIPART,
231
- parameters: { creation_time: asset.creationTime.toString() },
232
- headers: { authorization: `Bearer ${MOCK_TOKEN}` },
233
- });
234
- if (resp.status === 200) {
235
- synced = await addSyncedId(asset.id, synced);
236
- ok++;
237
- setItemsToBackup(prev => Math.max(0, prev - 1));
238
- }
239
- } catch (e) {
240
- console.error('Upload error', e);
241
- // If upload fails, we stop the sync to wait for better network
242
- Alert.alert('Sync Interrupted', 'Could not connect to KeyStone backend.');
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 val = await AsyncStorage.getItem(SYNC_ON_CELLULAR_KEY);
21
- if (val !== null) {
22
- setSyncOnCellular(val === 'true');
 
 
 
 
 
 
 
 
 
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",