diff --git a/formulus/android/app/src/main/AndroidManifest.xml b/formulus/android/app/src/main/AndroidManifest.xml index 70f779c69..14bd28bae 100644 --- a/formulus/android/app/src/main/AndroidManifest.xml +++ b/formulus/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -16,11 +17,13 @@ - + + + + + diff --git a/formulus/index.js b/formulus/index.js index 9b7393291..f60254560 100644 --- a/formulus/index.js +++ b/formulus/index.js @@ -2,8 +2,15 @@ * @format */ -import { AppRegistry } from 'react-native'; +import { AppRegistry, Platform } from 'react-native'; +import notifee from '@notifee/react-native'; import App from './App'; import { name as appName } from './app.json'; +if (Platform.OS === 'android') { + notifee.registerForegroundService(() => { + return new Promise(() => {}); + }); +} + AppRegistry.registerComponent(appName, () => App); diff --git a/formulus/package-lock.json b/formulus/package-lock.json index 446a0943a..f405a7ea1 100644 --- a/formulus/package-lock.json +++ b/formulus/package-lock.json @@ -34,7 +34,8 @@ "react-native-signature-canvas": "^5.0.2", "react-native-url-polyfill": "^3.0.0", "react-native-vision-camera": "^4.7.3", - "react-native-webview": "^13.16.0" + "react-native-webview": "^13.16.0", + "react-native-zip-archive": "^7.0.2" }, "devDependencies": { "@babel/core": "^7.28.5", @@ -13337,6 +13338,16 @@ "react-native": "*" } }, + "node_modules/react-native-zip-archive": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-native-zip-archive/-/react-native-zip-archive-7.0.2.tgz", + "integrity": "sha512-msCRJMcwH6NVZ2/zoC+1nvA0wlpYRnMxteQywS9nt4BzXn48tZpaVtE519QEZn0xe3ygvgsWx5cdPoE9Jx3bsg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.6", + "react-native": ">=0.60.0" + } + }, "node_modules/react-native/node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", diff --git a/formulus/package.json b/formulus/package.json index 06547f140..65f334e65 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -44,7 +44,8 @@ "react-native-signature-canvas": "^5.0.2", "react-native-url-polyfill": "^3.0.0", "react-native-vision-camera": "^4.7.3", - "react-native-webview": "^13.16.0" + "react-native-webview": "^13.16.0", + "react-native-zip-archive": "^7.0.2" }, "devDependencies": { "@babel/core": "^7.28.5", diff --git a/formulus/src/api/synkronus/index.ts b/formulus/src/api/synkronus/index.ts index a810cc171..69d770803 100644 --- a/formulus/src/api/synkronus/index.ts +++ b/formulus/src/api/synkronus/index.ts @@ -14,6 +14,7 @@ import { getApiAuthToken } from './Auth'; import { databaseService } from '../../database/DatabaseService'; import randomId from '@nozbe/watermelondb/utils/common/randomId'; import { clientIdService } from '../../services/ClientIdService'; +import { unzip } from 'react-native-zip-archive'; interface DownloadResult { success: boolean; @@ -153,6 +154,80 @@ class SynkronusApi { return response.data; } + /** + * Downloads the app bundle as a single zip, extracts to a temp directory, + * then atomically swaps into place so the old bundle stays intact until + * the new one is fully ready. + */ + async downloadAndInstallBundleZip( + progressCallback?: (progressPercent: number) => void, + ): Promise { + const config = await this.getConfig(); + const authToken = + this.fastGetToken_cachedToken ?? (await this.fastGetToken()); + + const zipUrl = `${config.basePath}/app-bundle/download-zip`; + const tempZipPath = `${RNFS.CachesDirectoryPath}/bundle_temp.zip`; + const tempExtractPath = `${RNFS.CachesDirectoryPath}/bundle_staging`; + const appDir = `${RNFS.DocumentDirectoryPath}/app`; + const formsDir = `${RNFS.DocumentDirectoryPath}/forms`; + + // Clean up any leftover temp artifacts + if (await RNFS.exists(tempZipPath)) await RNFS.unlink(tempZipPath); + if (await RNFS.exists(tempExtractPath)) await RNFS.unlink(tempExtractPath); + + // Download the zip + const downloadResult = await RNFS.downloadFile({ + fromUrl: zipUrl, + toFile: tempZipPath, + headers: { Authorization: `Bearer ${authToken}` }, + background: true, + progressInterval: 500, + progress: res => { + if (res.contentLength > 0) { + const percent = Math.round( + (res.bytesWritten / res.contentLength) * 50, + ); + progressCallback?.(percent); + } + }, + }).promise; + + if (downloadResult.statusCode !== 200) { + if (await RNFS.exists(tempZipPath)) await RNFS.unlink(tempZipPath); + throw new Error( + `Bundle zip download failed (HTTP ${downloadResult.statusCode})`, + ); + } + + progressCallback?.(50); + + // Extract to staging directory + await RNFS.mkdir(tempExtractPath); + await unzip(tempZipPath, tempExtractPath); + progressCallback?.(80); + + // Atomic swap: remove old dirs, move staging content into place + if (await RNFS.exists(appDir)) await RNFS.unlink(appDir); + if (await RNFS.exists(formsDir)) await RNFS.unlink(formsDir); + + const stagingAppDir = `${tempExtractPath}/app`; + const stagingFormsDir = `${tempExtractPath}/forms`; + + if (await RNFS.exists(stagingAppDir)) + await RNFS.moveFile(stagingAppDir, appDir); + if (await RNFS.exists(stagingFormsDir)) + await RNFS.moveFile(stagingFormsDir, formsDir); + + progressCallback?.(95); + + // Clean up temp files + if (await RNFS.exists(tempZipPath)) await RNFS.unlink(tempZipPath); + if (await RNFS.exists(tempExtractPath)) await RNFS.unlink(tempExtractPath); + + progressCallback?.(100); + } + private getAttachmentsDownloadManifest( observations: Observation[], ): string[] { diff --git a/formulus/src/contexts/SyncContext.tsx b/formulus/src/contexts/SyncContext.tsx index 30a0cf209..4bed1a780 100644 --- a/formulus/src/contexts/SyncContext.tsx +++ b/formulus/src/contexts/SyncContext.tsx @@ -5,6 +5,7 @@ import React, { useCallback, ReactNode, } from 'react'; +import { syncService as syncServiceInstance } from '../services/SyncService'; export interface SyncProgress { current: number; @@ -70,6 +71,7 @@ export const SyncProvider: React.FC = ({ children }) => { }, []); const cancelSync = useCallback(() => { + syncServiceInstance.cancelSync(); setSyncState(prev => ({ ...prev, isActive: false, diff --git a/formulus/src/screens/HomeScreen.tsx b/formulus/src/screens/HomeScreen.tsx index d9eefe7f1..f8fd35dbd 100644 --- a/formulus/src/screens/HomeScreen.tsx +++ b/formulus/src/screens/HomeScreen.tsx @@ -13,11 +13,13 @@ import CustomAppWebView, { CustomAppWebViewHandle, } from '../components/CustomAppWebView'; import { colors } from '../theme/colors'; +import { appEvents, Listener } from '../webview/FormulusMessageHandlers'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const HomeScreen = ({ navigation }: { navigation: any }) => { const [localUri, setLocalUri] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [webViewKey, setWebViewKey] = useState(0); const customAppRef = useRef(null); useFocusEffect( @@ -94,6 +96,15 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { return unsubscribe; }, [navigation]); + useEffect(() => { + const onBundleUpdated: Listener = () => { + checkAndSetAppUri(); + setWebViewKey(prev => prev + 1); + }; + appEvents.addListener('bundleUpdated', onBundleUpdated); + return () => appEvents.removeListener('bundleUpdated', onBundleUpdated); + }, []); + useEffect(() => { if (localUri) { // Defer to avoid synchronous setState in effect @@ -121,6 +132,7 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { /> ) : ( { const syncContextValue = useSyncContext(); const { @@ -43,6 +45,7 @@ const SyncScreen = () => { const [serverBundleVersion, setServerBundleVersion] = useState('Unknown'); const [animatedProgress] = useState(new Animated.Value(0)); + const [activeOperation, setActiveOperation] = useState(null); const updatePendingUploads = useCallback(async () => { try { @@ -76,129 +79,155 @@ const SyncScreen = () => { } }, []); - const handleSync = useCallback(async () => { - if (syncState.isActive) { - console.log('Sync already active, ignoring request'); - return; + const refreshAfterOperation = useCallback(async () => { + const syncTime = new Date().toISOString(); + setLastSync(syncTime); + try { + await AsyncStorage.setItem('@lastSync', syncTime); + } catch (e) { + console.warn('Failed to save last sync time:', e); } + try { + await updatePendingUploads(); + } catch (e) { + console.warn('Failed to update pending uploads:', e); + } + try { + await updatePendingObservations(); + } catch (e) { + console.warn('Failed to update pending observations:', e); + } + }, [updatePendingUploads, updatePendingObservations]); + + const handleSync = useCallback(async () => { + if (syncState.isActive) return; let syncError: string | undefined; try { - console.log('Starting sync...'); startSync(true); + setActiveOperation('sync'); - // Add timeout to prevent infinite hanging (30 minutes max) const syncPromise = syncService.syncObservations(true); const timeoutPromise = new Promise((_, reject) => { setTimeout( - () => { - reject(new Error('Sync operation timed out after 30 minutes')); - }, + () => reject(new Error('Sync timed out after 30 minutes')), 30 * 60 * 1000, ); }); - const finalVersion = await Promise.race([syncPromise, timeoutPromise]); - console.log('✅ Sync completed successfully, version:', finalVersion); - - // Update UI state even if these fail - try { - await updatePendingUploads(); - } catch (e) { - console.warn('Failed to update pending uploads:', e); - } - - try { - await updatePendingObservations(); - } catch (e) { - console.warn('Failed to update pending observations:', e); - } - - const syncTime = new Date().toISOString(); - setLastSync(syncTime); - try { - await AsyncStorage.setItem('@lastSync', syncTime); - } catch (e) { - console.warn('Failed to save last sync time:', e); - } + await Promise.race([syncPromise, timeoutPromise]); + await refreshAfterOperation(); } catch (error) { - console.error('❌ Sync error in handleSync:', error); syncError = (error as Error).message || 'Unknown error occurred'; - Alert.alert('Error', 'Failed to sync!\n' + syncError); + Alert.alert('Sync Failed', syncError); } finally { - // Always call finishSync to clear loading state - console.log('Calling finishSync, error:', syncError); finishSync(syncError); - console.log('✅ Sync finished, isActive should be false now'); + setActiveOperation(null); } - }, [ - updatePendingUploads, - updatePendingObservations, - syncState.isActive, - startSync, - finishSync, - ]); - - const handleCustomAppUpdate = useCallback(async () => { - if (syncState.isActive) return; + }, [syncState.isActive, startSync, finishSync, refreshAfterOperation]); + const performAppBundleUpdate = useCallback(async () => { try { - const userInfo = await getUserInfo(); - if (!userInfo) { - Alert.alert( - 'Authentication Error', - 'Please log in to update app bundle', - ); - return; - } - - if (!updateAvailable && !isAdmin) { - Alert.alert( - 'Permission Denied', - 'Admin privileges required to force update app bundle', - ); - return; - } + startSync(true); + setActiveOperation('update'); - startSync(false); await syncService.updateAppBundle(); - const syncTime = new Date().toISOString(); - setLastSync(syncTime); - await AsyncStorage.setItem('@lastSync', syncTime); setUpdateAvailable(false); + await refreshAfterOperation(); finishSync(); - await updatePendingUploads(); - await updatePendingObservations(); + const formService = await import('../services/FormService'); const fs = await formService.FormService.getInstance(); await fs.invalidateCache(); } catch (error) { const errorMessage = (error as Error).message; finishSync(errorMessage); - if (errorMessage.includes('401')) { Alert.alert( 'Authentication Error', 'Your session has expired. Please log in again.', ); } else { - Alert.alert('Error', 'Failed to update app bundle!\n' + errorMessage); + Alert.alert('Update Failed', errorMessage); } + } finally { + setActiveOperation(null); + } + }, [startSync, finishSync, refreshAfterOperation]); + + const performSyncThenUpdate = useCallback(async () => { + try { + startSync(true); + setActiveOperation('sync_then_update'); + + await syncService.syncObservations(true); + await syncService.updateAppBundle(); + setUpdateAvailable(false); + await refreshAfterOperation(); + finishSync(); + + const formService = await import('../services/FormService'); + const fs = await formService.FormService.getInstance(); + await fs.invalidateCache(); + } catch (error) { + const errorMessage = (error as Error).message; + finishSync(errorMessage); + Alert.alert('Operation Failed', errorMessage); + } finally { + setActiveOperation(null); + } + }, [startSync, finishSync, refreshAfterOperation]); + + const handleCustomAppUpdate = useCallback(async () => { + if (syncState.isActive) return; + + const userInfo = await getUserInfo(); + if (!userInfo) { + Alert.alert('Authentication Error', 'Please log in to update app bundle'); + return; + } + + if (!updateAvailable && !isAdmin) { + Alert.alert( + 'Permission Denied', + 'Admin privileges required to force update app bundle', + ); + return; + } + + const hasPendingData = pendingObservations > 0 || pendingUploads.count > 0; + + if (hasPendingData) { + const pendingCount = pendingObservations + pendingUploads.count; + Alert.alert( + 'Unsynchronized Data', + `You have ${pendingCount} unsynchronized item${pendingCount !== 1 ? 's' : ''}. Sync your data before updating to avoid data loss.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Sync & Update', + onPress: () => performSyncThenUpdate(), + }, + ], + ); + return; } + + await performAppBundleUpdate(); }, [ syncState.isActive, - startSync, - finishSync, - updatePendingUploads, - updatePendingObservations, updateAvailable, isAdmin, + pendingObservations, + pendingUploads.count, + performAppBundleUpdate, + performSyncThenUpdate, ]); - const checkForUpdates = useCallback(async (force: boolean = false) => { + const checkForUpdates = useCallback(async () => { try { - const hasUpdate = await syncService.checkForUpdates(force); + const hasUpdate = await syncService.checkForUpdates(); setUpdateAvailable(hasUpdate); const currentVersion = (await AsyncStorage.getItem('@appVersion')) || '0'; setAppBundleVersion(currentVersion); @@ -214,20 +243,20 @@ const SyncScreen = () => { } }, []); - const getDataSyncStatus = (): string => { + const getStatusText = (): string => { if (syncState.isActive) { - return syncState.progress?.details || 'Syncing...'; - } - if (syncState.error) { - return 'Error'; - } - if (pendingObservations > 0 || pendingUploads.count > 0) { - return 'Pending Sync'; + if (activeOperation === 'update') return 'Updating app...'; + if (activeOperation === 'sync_then_update') + return 'Syncing & updating...'; + return 'Syncing...'; } + if (syncState.error) return 'Error'; + if (pendingObservations > 0 || pendingUploads.count > 0) + return 'Pending sync'; return 'All synced'; }; - const status = getDataSyncStatus(); + const status = getStatusText(); const statusColor = syncState.isActive ? colors.brand.primary[500] : syncState.error @@ -243,7 +272,7 @@ const SyncScreen = () => { const initialize = async () => { await syncService.initialize(); - await checkForUpdates(true); + await checkForUpdates(); const userInfo = await getUserInfo(); setIsAdmin(userInfo?.role === 'admin'); const lastSyncTime = await AsyncStorage.getItem('@lastSync'); @@ -267,7 +296,6 @@ const SyncScreen = () => { updateProgress, ]); - // Smoothly animate progress changes so the bar doesn't "twitch" useEffect(() => { if (!syncState.progress || !syncState.isActive) { Animated.timing(animatedProgress, { @@ -297,7 +325,7 @@ const SyncScreen = () => { if (!syncState.isActive && !syncState.error) { updatePendingUploads(); updatePendingObservations(); - checkForUpdates(false); + checkForUpdates(); } }, [ syncState.isActive, @@ -307,13 +335,22 @@ const SyncScreen = () => { checkForUpdates, ]); + const getProgressTitle = (): string => { + if (activeOperation === 'sync_then_update') return 'Syncing & Updating'; + if (activeOperation === 'update') return 'Updating App Bundle'; + return 'Syncing Data'; + }; + + const isSyncButtonActive = + activeOperation === 'sync' || activeOperation === 'sync_then_update'; + const isUpdateButtonActive = + activeOperation === 'update' || activeOperation === 'sync_then_update'; + return ( Sync - - {syncState.isActive ? 'Syncing...' : 'Synchronize your data'} - + Synchronize your data { - Sync Progress + {getProgressTitle()} - - {syncState.progress.phase === 'attachments_download' - ? 'App bundle update' - : 'Data sync'} - - - {syncState.progress.details || 'Syncing...'} - { /> - {syncState.progress.current}/{syncState.progress.total} -{' '} {Math.round( (syncState.progress.current / syncState.progress.total) * 100, )} @@ -521,13 +549,13 @@ const SyncScreen = () => { ]} onPress={handleSync} disabled={syncState.isActive}> - {syncState.isActive ? ( + {isSyncButtonActive ? ( ) : ( )} - {syncState.isActive ? 'Syncing...' : 'Sync Data'} + {isSyncButtonActive ? 'Syncing...' : 'Sync Data'} @@ -540,7 +568,7 @@ const SyncScreen = () => { ]} onPress={handleCustomAppUpdate} disabled={syncState.isActive || (!updateAvailable && !isAdmin)}> - {syncState.isActive ? ( + {isUpdateButtonActive ? ( { /> )} - {syncState.isActive ? 'Updating...' : 'Update App Bundle'} + {isUpdateButtonActive ? 'Updating...' : 'Update App Bundle'} - {updateAvailable && ( + {!syncState.isActive && updateAvailable && ( Update available )} - {!updateAvailable && !isAdmin && ( + {!syncState.isActive && !updateAvailable && !isAdmin && ( No updates available )} @@ -750,17 +778,6 @@ const styles = StyleSheet.create({ fontWeight: '600', color: colors.brand.primary[500], }, - progressDetails: { - fontSize: 14, - color: colors.neutral[600], - marginBottom: 12, - }, - progressMode: { - fontSize: 12, - color: colors.neutral[500], - marginBottom: 4, - fontStyle: 'italic', - }, progressBar: { height: 8, backgroundColor: colors.brand.primary[200], diff --git a/formulus/src/services/NotificationService.ts b/formulus/src/services/NotificationService.ts index 95fd07cda..bfccb1b9a 100644 --- a/formulus/src/services/NotificationService.ts +++ b/formulus/src/services/NotificationService.ts @@ -1,110 +1,107 @@ +import { Platform } from 'react-native'; import notifee, { AndroidImportance, - AndroidStyle, - AndroidAction, + AndroidForegroundServiceType, } from '@notifee/react-native'; import { SyncProgress } from '../contexts/SyncContext'; class NotificationService { private syncNotificationId = 'sync_progress'; - private completionNotificationId = 'sync_completion'; private channelId = 'sync_channel'; private isConfigured = false; + private foregroundServiceRunning = false; async configure() { if (this.isConfigured) return; - - // Request permissions await notifee.requestPermission(); - - // Create notification channel for Android await notifee.createChannel({ id: this.channelId, name: 'Sync Progress', description: 'Shows progress of data synchronization', importance: AndroidImportance.DEFAULT, - sound: undefined, // No sound + sound: undefined, vibration: false, }); - this.isConfigured = true; - console.log('Notifee notification service configured'); } async showSyncProgress(progress: SyncProgress) { - await this.configure(); + if (!this.foregroundServiceRunning) return; const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; - const phaseText = this.getPhaseText(progress.phase); - - const title = 'Syncing data...'; - let message = `${phaseText}: ${progress.current}/${progress.total}`; - if (progress.details) { - message += ` - ${progress.details}`; + try { + await notifee.displayNotification({ + id: this.syncNotificationId, + title: this.getPhaseText(progress.phase), + body: `${percentage}%`, + android: { + channelId: this.channelId, + ongoing: true, + progress: { + max: 100, + current: percentage, + indeterminate: progress.total === 0, + }, + }, + }); + } catch (e) { + console.warn('Failed to update sync progress notification:', e); } + } - const cancelAction: AndroidAction = { - title: 'Cancel', - pressAction: { - id: 'cancel_sync', - }, - }; + async startForegroundService() { + if (Platform.OS !== 'android' || this.foregroundServiceRunning) return; + await this.configure(); await notifee.displayNotification({ id: this.syncNotificationId, - title, - body: message, + title: 'Syncing...', + body: 'Starting...', android: { channelId: this.channelId, - ongoing: true, // Makes notification persistent - style: { - type: AndroidStyle.BIGTEXT, - text: message, - }, - progress: { - max: 100, - current: percentage, - indeterminate: progress.total === 0, - }, - actions: [cancelAction], - pressAction: { - id: 'default', - }, + asForegroundService: true, + foregroundServiceTypes: [ + AndroidForegroundServiceType.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ], + ongoing: true, + progress: { max: 100, current: 0, indeterminate: true }, }, }); + this.foregroundServiceRunning = true; + } + + async stopForegroundService() { + if (Platform.OS !== 'android' || !this.foregroundServiceRunning) return; + this.foregroundServiceRunning = false; + try { + await notifee.stopForegroundService(); + } catch (e) { + console.warn('Failed to stop foreground service:', e); + } + try { + await notifee.cancelNotification(this.syncNotificationId); + } catch (e) { + console.warn('Failed to cancel foreground notification:', e); + } + // Delayed cleanup: catch any fire-and-forget showSyncProgress calls + // that were already in-flight when we stopped the service + setTimeout(async () => { + try { + await notifee.cancelNotification(this.syncNotificationId); + } catch (_) { + // ignore + } + }, 1000); } async showSyncComplete(success: boolean, error?: string) { await this.configure(); - // Cancel the ongoing notification completely - console.log( - 'Canceling progress notification with ID:', - this.syncNotificationId, - ); - - // First, update the notification to make it non-ongoing, then cancel it - await notifee.displayNotification({ - id: this.syncNotificationId, - title: 'Sync completing...', - body: 'Finalizing sync...', - android: { - channelId: this.channelId, - ongoing: false, // Make it non-ongoing so it can be cancelled - autoCancel: true, - }, - }); - - // Now cancel it - await notifee.cancelNotification(this.syncNotificationId); - console.log('Progress notification cancellation completed'); - if (success) { - // Show a fresh completion notification with timestamp const now = new Date(); const timeString = now.toLocaleTimeString('en-US', { hour12: false, @@ -113,34 +110,26 @@ class NotificationService { }); await notifee.displayNotification({ - id: `sync_completed_${Date.now()}`, // Unique ID each time + id: `sync_done_${Date.now()}`, title: `Sync completed @ ${timeString}`, body: 'All data synchronized successfully', android: { channelId: this.channelId, autoCancel: true, - smallIcon: 'ic_launcher', ongoing: false, - // No actions at all - completely fresh notification - pressAction: { - id: 'default', - }, + pressAction: { id: 'default' }, }, }); } else { - // For errors, show a simple error notification await notifee.displayNotification({ - id: `sync_failed_${Date.now()}`, // Unique ID each time + id: `sync_done_${Date.now()}`, title: 'Sync failed', body: error || 'An error occurred during synchronization', android: { channelId: this.channelId, autoCancel: true, - smallIcon: 'ic_launcher', ongoing: false, - pressAction: { - id: 'default', - }, + pressAction: { id: 'default' }, }, }); } @@ -148,27 +137,15 @@ class NotificationService { async showSyncCanceled() { await this.configure(); - - // Remove the ongoing notification - await notifee.cancelNotification(this.syncNotificationId); - - // Small delay to ensure the previous notification is fully canceled - await new Promise(resolve => setTimeout(() => resolve(), 100)); - - // Show cancellation notification with different ID await notifee.displayNotification({ - id: `${this.completionNotificationId}_canceled`, + id: `sync_done_${Date.now()}`, title: 'Sync canceled', - body: 'Data synchronization was canceled by user', + body: 'Synchronization was canceled', android: { channelId: this.channelId, autoCancel: true, - smallIcon: 'ic_launcher', - pressAction: { - id: 'default', - }, - actions: [], // Explicitly remove all actions (no Cancel button) - ongoing: false, // Ensure it's not ongoing + ongoing: false, + pressAction: { id: 'default' }, }, }); } @@ -178,22 +155,17 @@ class NotificationService { } async clearAllSyncNotifications() { - // Clear all sync-related notifications to prevent stale data - await notifee.cancelNotification(this.syncNotificationId); - await notifee.cancelNotification(this.completionNotificationId); - await notifee.cancelNotification( - `${this.completionNotificationId}_canceled`, - ); + await notifee.cancelAllNotifications(); } private getPhaseText(phase: SyncProgress['phase']): string { switch (phase) { case 'pull': - return 'Downloading'; + return 'Downloading data'; case 'push': return 'Uploading observations'; case 'attachments_download': - return 'Downloading attachments'; + return 'Updating app bundle'; case 'attachments_upload': return 'Uploading attachments'; default: diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index 52046f5c1..0697ba9a4 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -1,6 +1,6 @@ import { synkronusApi } from '../api/synkronus'; -import RNFS from 'react-native-fs'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { appEvents } from '../webview/FormulusMessageHandlers'; import { SyncProgress } from '../contexts/SyncContext'; import { notificationService } from './NotificationService'; import { FormService } from './FormService'; @@ -165,10 +165,9 @@ export class SyncService { this.isSyncing = true; this.canCancel = true; this.shouldCancel = false; - this.autoLoginRetryCount = 0; // Reset retry count for new sync operation + this.autoLoginRetryCount = 0; this.updateStatus('Starting sync...'); - // Clear any stale notifications before starting new sync notificationService .clearAllSyncNotifications() .catch(error => @@ -176,6 +175,7 @@ export class SyncService { ); try { + await notificationService.startForegroundService(); // Phase 1: Pull - Get manifest and download changes this.updateProgress({ current: 0, @@ -291,7 +291,7 @@ export class SyncService { this.isSyncing = false; this.canCancel = false; this.shouldCancel = false; - // Note: Don't call hideSyncProgress() here as showSyncComplete() already handles notification cleanup + await notificationService.stopForegroundService(); } } @@ -324,9 +324,10 @@ export class SyncService { } this.isSyncing = true; - this.autoLoginRetryCount = 0; // Reset retry count for new bundle update + this.canCancel = true; + this.shouldCancel = false; + this.autoLoginRetryCount = 0; this.updateStatus('Starting app bundle sync...'); - // Expose progress to the UI so users can see bundle download progress. this.updateProgress({ current: 0, total: 100, @@ -335,12 +336,17 @@ export class SyncService { }); try { - // Get manifest to know what version we're downloading + await notificationService.startForegroundService(); + + if (this.shouldCancel) throw new Error('Sync cancelled'); + const manifest = await this.withAutoLoginRetry( () => synkronusApi.getManifest(), 'get manifest', ); + if (this.shouldCancel) throw new Error('Sync cancelled'); + await this.downloadAppBundle(); // Save the version after successful download @@ -360,79 +366,37 @@ export class SyncService { phase: 'attachments_download', details: 'App bundle sync completed', }); + + appEvents.emit('bundleUpdated'); } catch (error) { console.error('App sync failed', error); this.updateStatus('App sync failed'); throw error; } finally { this.isSyncing = false; + this.canCancel = false; + this.shouldCancel = false; + await notificationService.stopForegroundService(); } } private async downloadAppBundle(): Promise { try { - this.updateStatus('Fetching manifest...'); - const manifest = await this.withAutoLoginRetry( - () => synkronusApi.getManifest(), - 'get manifest', - ); - - // Clean out the existing app bundle - await synkronusApi.removeAppBundleFiles(); - - // Download form specs - this.updateStatus('Downloading form specs...'); - const formResults = await this.withAutoLoginRetry( - () => - synkronusApi.downloadFormSpecs( - manifest, - RNFS.DocumentDirectoryPath, - progress => { - const normalized = Math.max(0, Math.min(100, progress)); - this.updateStatus(`Downloading form specs... ${normalized}%`); - // Use 0–50% of the overall range for form specs - this.updateProgress({ - current: Math.round((normalized / 100) * 50), - total: 100, - phase: 'attachments_download', - details: `Downloading form specs... ${normalized}%`, - }); - }, - ), - 'download form specs', - ); - - // Download app files - this.updateStatus('Downloading app files...'); - const appResults = await this.withAutoLoginRetry( + this.updateStatus('Downloading app bundle...'); + await this.withAutoLoginRetry( () => - synkronusApi.downloadAppFiles( - manifest, - RNFS.DocumentDirectoryPath, - progress => { - const normalized = Math.max(0, Math.min(100, progress)); - this.updateStatus(`Downloading app files... ${normalized}%`); - // Use 50–100% of the overall range for app files - this.updateProgress({ - current: 50 + Math.round((normalized / 100) * 50), - total: 100, - phase: 'attachments_download', - details: `Downloading app files... ${normalized}%`, - }); - }, - ), - 'download app files', + synkronusApi.downloadAndInstallBundleZip(progress => { + const normalized = Math.max(0, Math.min(100, progress)); + this.updateStatus(`Downloading app bundle... ${normalized}%`); + this.updateProgress({ + current: normalized, + total: 100, + phase: 'attachments_download', + details: `Downloading app bundle... ${normalized}%`, + }); + }), + 'download app bundle', ); - - const results = [...formResults, ...appResults]; - - if (results.some(r => !r.success)) { - const errorMessages = results - .filter(r => !r.success) - .map(r => r.message) - .join('\n'); - throw new Error(`Failed to download some files:\n${errorMessages}`); - } } catch (error) { console.error('Download failed', error); throw error; diff --git a/synkronus/internal/api/api.go b/synkronus/internal/api/api.go index ba41c1cab..72f47b693 100644 --- a/synkronus/internal/api/api.go +++ b/synkronus/internal/api/api.go @@ -128,6 +128,7 @@ func NewRouter(log *logger.Logger, h *handlers.Handler) http.Handler { // Read endpoints - accessible to all authenticated users r.Get("/manifest", h.GetAppBundleManifest) r.Get("/download/{path}", h.GetAppBundleFile) + r.Get("/download-zip", h.DownloadBundleZip) r.Get("/versions", h.GetAppBundleVersions) r.Get("/changes", h.CompareAppBundleVersions) diff --git a/synkronus/internal/handlers/appbundle.go b/synkronus/internal/handlers/appbundle.go index 1657a9af8..7c568fde2 100644 --- a/synkronus/internal/handlers/appbundle.go +++ b/synkronus/internal/handlers/appbundle.go @@ -125,6 +125,20 @@ func (h *Handler) streamFile(w http.ResponseWriter, file io.ReadCloser, fileInfo } } +// DownloadBundleZip serves the active app bundle as a zip file +func (h *Handler) DownloadBundleZip(w http.ResponseWriter, r *http.Request) { + zipPath, err := h.appBundleService.GetBundleZipPath(r.Context()) + if err != nil { + h.log.Error("Failed to get bundle zip", "error", err) + SendErrorResponse(w, http.StatusNotFound, err, "Bundle zip not available") + return + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="bundle.zip"`) + http.ServeFile(w, r, zipPath) +} + // CompareAppBundleVersions handles the /app-bundle/changes endpoint func (h *Handler) CompareAppBundleVersions(w http.ResponseWriter, r *http.Request) { h.log.Info("App bundle comparison requested") diff --git a/synkronus/internal/handlers/mocks/appbundle_service.go b/synkronus/internal/handlers/mocks/appbundle_service.go index 866f28ee4..ed1be5166 100644 --- a/synkronus/internal/handlers/mocks/appbundle_service.go +++ b/synkronus/internal/handlers/mocks/appbundle_service.go @@ -165,6 +165,11 @@ func (m *MockAppBundleService) GetLatestAppInfo(ctx context.Context) (*appbundle }, nil } +// GetBundleZipPath returns the path to the active bundle's zip archive +func (m *MockAppBundleService) GetBundleZipPath(ctx context.Context) (string, error) { + return "/mock/bundle.zip", nil +} + // CompareAppInfos compares two versions and returns the change log func (m *MockAppBundleService) CompareAppInfos(ctx context.Context, versionA, versionB string) (*appbundle.ChangeLog, error) { // Return a mock change log diff --git a/synkronus/internal/handlers/sync_e2e_test.go b/synkronus/internal/handlers/sync_e2e_test.go index f7c65af4b..89c10be41 100644 --- a/synkronus/internal/handlers/sync_e2e_test.go +++ b/synkronus/internal/handlers/sync_e2e_test.go @@ -348,6 +348,9 @@ func (m *mockAppBundleService) GetLatestAppInfo(ctx context.Context) (*appbundle func (m *mockAppBundleService) CompareAppInfos(ctx context.Context, versionA, versionB string) (*appbundle.ChangeLog, error) { return &appbundle.ChangeLog{}, nil } +func (m *mockAppBundleService) GetBundleZipPath(ctx context.Context) (string, error) { + return "/mock/bundle.zip", nil +} type mockUserService struct{} diff --git a/synkronus/pkg/appbundle/interface.go b/synkronus/pkg/appbundle/interface.go index 2fa865e2b..3e899a4aa 100644 --- a/synkronus/pkg/appbundle/interface.go +++ b/synkronus/pkg/appbundle/interface.go @@ -63,4 +63,7 @@ type AppBundleServiceInterface interface { // CompareAppInfos compares two versions and returns the change log CompareAppInfos(ctx context.Context, versionA, versionB string) (*ChangeLog, error) + + // GetBundleZipPath returns the filesystem path to the active bundle's zip archive + GetBundleZipPath(ctx context.Context) (string, error) } diff --git a/synkronus/pkg/appbundle/service.go b/synkronus/pkg/appbundle/service.go index a484ee529..529d2456c 100644 --- a/synkronus/pkg/appbundle/service.go +++ b/synkronus/pkg/appbundle/service.go @@ -291,6 +291,10 @@ func (s *Service) generateManifest() (*Manifest, error) { // Use forward slashes for consistency across platforms relPath = filepath.ToSlash(relPath) + if relPath == "bundle.zip" { + return nil + } + // Include app/forms/ in the manifest. Some bundles (e.g. AnthroCollect) put // form schemas only under app/forms//schema.json. The Formulus // client downloads these via downloadAppFiles (prefix "app/") and @@ -416,7 +420,7 @@ func (s *Service) hashManifest(manifest *Manifest) (string, error) { // ensureCurrentVersionSet checks if a current version is set, and if not, // sets the latest available version as current -func (s *Service) ensureCurrentVersionSet(ctx context.Context) error { +func (s *Service) ensureCurrentVersionSet(_ context.Context) error { // Check if CURRENT_VERSION file exists versionFile := filepath.Join(s.versionsPath, "CURRENT_VERSION") if _, err := os.Stat(versionFile); err == nil { @@ -467,6 +471,18 @@ func (s *Service) ensureCurrentVersionSet(ctx context.Context) error { return nil } +// GetBundleZipPath returns the filesystem path to the active bundle's zip archive +func (s *Service) GetBundleZipPath(_ context.Context) (string, error) { + zipPath := filepath.Join(s.bundlePath, "bundle.zip") + if _, err := os.Stat(zipPath); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("bundle zip not available") + } + return "", fmt.Errorf("failed to check bundle zip: %w", err) + } + return zipPath, nil +} + // RefreshManifest forces a refresh of the manifest func (s *Service) RefreshManifest() error { manifest, err := s.generateManifest() diff --git a/synkronus/pkg/appbundle/validation.go b/synkronus/pkg/appbundle/validation.go index 801134fae..221d843eb 100644 --- a/synkronus/pkg/appbundle/validation.go +++ b/synkronus/pkg/appbundle/validation.go @@ -60,13 +60,13 @@ func (s *Service) validateBundleStructure(zipReader *zip.Reader) error { formDirs[formParts[1]] = struct{}{} } } - // AnthroCollect-style: app/forms/{formName}/... + // AnthroCollect-style: app/forms/{formName}/schema.json|ui.json if strings.HasPrefix(file.Name, "app/forms/") && !strings.HasSuffix(file.Name, "/") { if file.Name == "app/forms/ext.json" || strings.HasSuffix(file.Name, "/ext.json") { continue } formParts := strings.Split(file.Name, "/") - if len(formParts) >= 3 { + if len(formParts) == 4 && (formParts[3] == "schema.json" || formParts[3] == "ui.json") { formDirs[formParts[2]] = struct{}{} } } @@ -152,14 +152,18 @@ func getFormNameFromSchemaPath(path string) string { return "" } -// validateFormFileAppForms validates app/forms/{formName}/schema.json or ui.json +// validateFormFileAppForms validates app/forms/{formName}/schema.json or ui.json. +// Files at other depths (e.g. extensions, helpers) are allowed and skipped. func (s *Service) validateFormFileAppForms(file *zip.File) error { if file.FileInfo().IsDir() { return nil } parts := strings.Split(file.Name, "/") - if len(parts) != 4 || (parts[3] != "schema.json" && parts[3] != "ui.json") { - return fmt.Errorf("%w: invalid form file path: %s", ErrInvalidFormStructure, file.Name) + if len(parts) != 4 { + return nil + } + if parts[3] != "schema.json" && parts[3] != "ui.json" { + return nil } if parts[3] == "schema.json" { return s.validateFormSchema(file) diff --git a/synkronus/pkg/appbundle/versioning.go b/synkronus/pkg/appbundle/versioning.go index e26127fb8..48279b4f7 100644 --- a/synkronus/pkg/appbundle/versioning.go +++ b/synkronus/pkg/appbundle/versioning.go @@ -123,6 +123,21 @@ func (s *Service) PushBundle(ctx context.Context, zipReader io.Reader) (*Manifes dstFile.Close() } + // Save the original zip to the version directory for direct download + if _, err := tempZipFile.Seek(0, 0); err != nil { + return nil, fmt.Errorf("failed to rewind zip for saving: %w", err) + } + bundleZipPath := filepath.Join(versionPath, "bundle.zip") + bundleZipFile, err := os.Create(bundleZipPath) + if err != nil { + return nil, fmt.Errorf("failed to create bundle.zip: %w", err) + } + if _, err := io.Copy(bundleZipFile, tempZipFile); err != nil { + bundleZipFile.Close() + return nil, fmt.Errorf("failed to save bundle.zip: %w", err) + } + bundleZipFile.Close() + // Clean up old versions if needed if err := s.cleanupOldVersions(); err != nil { s.log.Error("Failed to clean up old versions", "error", err)