From 17862a9cee37494ab9abef73ab40c33033cb51cb Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 01:46:16 +0300 Subject: [PATCH 01/12] synkronus: save zip on push and add GET /app-bundle/download-zip endpoint --- synkronus/internal/api/api.go | 1 + synkronus/internal/handlers/appbundle.go | 14 ++++++++++++++ .../internal/handlers/mocks/appbundle_service.go | 5 +++++ synkronus/pkg/appbundle/interface.go | 3 +++ synkronus/pkg/appbundle/service.go | 16 ++++++++++++++++ synkronus/pkg/appbundle/versioning.go | 15 +++++++++++++++ 6 files changed, 54 insertions(+) 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/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..5fd17e56b 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 @@ -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(ctx 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/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) From 28109532acc8f6f40aaf963ef3dfb9e9c20fa751 Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 01:50:48 +0300 Subject: [PATCH 02/12] formulus: zip-based app bundle download with transactional swap --- formulus/package-lock.json | 13 ++++- formulus/package.json | 3 +- formulus/src/api/synkronus/index.ts | 75 ++++++++++++++++++++++++++++ formulus/src/services/SyncService.ts | 74 +++++---------------------- 4 files changed, 102 insertions(+), 63 deletions(-) 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/services/SyncService.ts b/formulus/src/services/SyncService.ts index 52046f5c1..ae11792a3 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -1,5 +1,4 @@ import { synkronusApi } from '../api/synkronus'; -import RNFS from 'react-native-fs'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { SyncProgress } from '../contexts/SyncContext'; import { notificationService } from './NotificationService'; @@ -371,68 +370,21 @@ export class SyncService { 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( + this.updateStatus('Downloading app bundle...'); + 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', + 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', ); - - // Download app files - this.updateStatus('Downloading app files...'); - const appResults = 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', - ); - - 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; From 14eefa95272ee2c113f0caf519bf22491b598a88 Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 01:52:00 +0300 Subject: [PATCH 03/12] formulus: fix cancel button for sync and app bundle update --- formulus/src/contexts/SyncContext.tsx | 2 ++ formulus/src/screens/SyncScreen.tsx | 2 +- formulus/src/services/SyncService.ts | 12 +++++++++--- 3 files changed, 12 insertions(+), 4 deletions(-) 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/SyncScreen.tsx b/formulus/src/screens/SyncScreen.tsx index 92c855fc5..3610735ca 100644 --- a/formulus/src/screens/SyncScreen.tsx +++ b/formulus/src/screens/SyncScreen.tsx @@ -161,7 +161,7 @@ const SyncScreen = () => { return; } - startSync(false); + startSync(true); await syncService.updateAppBundle(); const syncTime = new Date().toISOString(); setLastSync(syncTime); diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index ae11792a3..8066eb2f3 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -323,9 +323,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, @@ -334,12 +335,15 @@ export class SyncService { }); try { - // Get manifest to know what version we're downloading + 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 @@ -365,6 +369,8 @@ export class SyncService { throw error; } finally { this.isSyncing = false; + this.canCancel = false; + this.shouldCancel = false; } } From f5fbe6856b5881ed255f3619c8a526f43c1df03c Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 01:53:15 +0300 Subject: [PATCH 04/12] formulus: simplify progress text to show only percentage --- formulus/src/screens/SyncScreen.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/formulus/src/screens/SyncScreen.tsx b/formulus/src/screens/SyncScreen.tsx index 3610735ca..27e19e4cd 100644 --- a/formulus/src/screens/SyncScreen.tsx +++ b/formulus/src/screens/SyncScreen.tsx @@ -479,7 +479,6 @@ const SyncScreen = () => { /> - {syncState.progress.current}/{syncState.progress.total} -{' '} {Math.round( (syncState.progress.current / syncState.progress.total) * 100, )} From f3946900bd07056f57e481950dca201c82ea9c44 Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 01:54:45 +0300 Subject: [PATCH 05/12] formulus: reload HomeScreen WebView after app bundle update --- formulus/src/screens/HomeScreen.tsx | 9 +++++++++ formulus/src/services/SyncService.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/formulus/src/screens/HomeScreen.tsx b/formulus/src/screens/HomeScreen.tsx index d9eefe7f1..bb7bd1ce2 100644 --- a/formulus/src/screens/HomeScreen.tsx +++ b/formulus/src/screens/HomeScreen.tsx @@ -13,6 +13,7 @@ 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 }) => { @@ -94,6 +95,14 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { return unsubscribe; }, [navigation]); + useEffect(() => { + const onBundleUpdated: Listener = () => { + checkAndSetAppUri(); + }; + appEvents.addListener('bundleUpdated', onBundleUpdated); + return () => appEvents.removeListener('bundleUpdated', onBundleUpdated); + }, []); + useEffect(() => { if (localUri) { // Defer to avoid synchronous setState in effect diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index 8066eb2f3..0db7a2027 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -1,5 +1,6 @@ import { synkronusApi } from '../api/synkronus'; 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'; @@ -363,6 +364,8 @@ 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'); From fe9ced8ded856a7cfaafd98bd2db56f65f954ab3 Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 01:57:45 +0300 Subject: [PATCH 06/12] formulus: add Android foreground service to keep sync alive in background --- .../android/app/src/main/AndroidManifest.xml | 4 ++- formulus/index.js | 9 +++++- formulus/src/services/NotificationService.ts | 32 +++++++++++++++++++ formulus/src/services/SyncService.ts | 10 ++++-- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/formulus/android/app/src/main/AndroidManifest.xml b/formulus/android/app/src/main/AndroidManifest.xml index 70f779c69..ea263c823 100644 --- a/formulus/android/app/src/main/AndroidManifest.xml +++ b/formulus/android/app/src/main/AndroidManifest.xml @@ -16,11 +16,13 @@ - + + + { + return new Promise(() => {}); + }); +} + AppRegistry.registerComponent(appName, () => App); diff --git a/formulus/src/services/NotificationService.ts b/formulus/src/services/NotificationService.ts index 95fd07cda..99c1453f9 100644 --- a/formulus/src/services/NotificationService.ts +++ b/formulus/src/services/NotificationService.ts @@ -1,7 +1,9 @@ +import { Platform } from 'react-native'; import notifee, { AndroidImportance, AndroidStyle, AndroidAction, + AndroidForegroundServiceType, } from '@notifee/react-native'; import { SyncProgress } from '../contexts/SyncContext'; @@ -10,6 +12,7 @@ class NotificationService { private completionNotificationId = 'sync_completion'; private channelId = 'sync_channel'; private isConfigured = false; + private foregroundServiceRunning = false; async configure() { if (this.isConfigured) return; @@ -173,6 +176,35 @@ class NotificationService { }); } + async startForegroundService() { + if (Platform.OS !== 'android' || this.foregroundServiceRunning) return; + await this.configure(); + + await notifee.displayNotification({ + id: this.syncNotificationId, + title: 'Syncing data...', + body: 'Sync in progress', + android: { + channelId: this.channelId, + asForegroundService: true, + foregroundServiceTypes: [AndroidForegroundServiceType.DATA_SYNC], + ongoing: true, + progress: { max: 100, current: 0, indeterminate: true }, + }, + }); + this.foregroundServiceRunning = true; + } + + async stopForegroundService() { + if (Platform.OS !== 'android' || !this.foregroundServiceRunning) return; + try { + await notifee.stopForegroundService(); + } catch (e) { + console.warn('Failed to stop foreground service:', e); + } + this.foregroundServiceRunning = false; + } + async hideSyncProgress() { await notifee.cancelNotification(this.syncNotificationId); } diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index 0db7a2027..4e1df53ef 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -165,16 +165,17 @@ 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 => console.warn('Failed to clear stale notifications:', error), ); + await notificationService.startForegroundService(); + try { // Phase 1: Pull - Get manifest and download changes this.updateProgress({ @@ -291,7 +292,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(); } } @@ -335,6 +336,8 @@ export class SyncService { details: 'Preparing app bundle download...', }); + await notificationService.startForegroundService(); + try { if (this.shouldCancel) throw new Error('Sync cancelled'); @@ -374,6 +377,7 @@ export class SyncService { this.isSyncing = false; this.canCancel = false; this.shouldCancel = false; + await notificationService.stopForegroundService(); } } From af9985d9bc1fc99acf9cb28834897269ab882c06 Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 02:03:28 +0300 Subject: [PATCH 07/12] fix: resolve lint errors from sync changes --- formulus/src/screens/SyncScreen.tsx | 8 ++++---- synkronus/internal/handlers/sync_e2e_test.go | 3 +++ synkronus/pkg/appbundle/service.go | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/formulus/src/screens/SyncScreen.tsx b/formulus/src/screens/SyncScreen.tsx index 27e19e4cd..6449bd371 100644 --- a/formulus/src/screens/SyncScreen.tsx +++ b/formulus/src/screens/SyncScreen.tsx @@ -196,9 +196,9 @@ const SyncScreen = () => { isAdmin, ]); - 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); @@ -243,7 +243,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'); @@ -297,7 +297,7 @@ const SyncScreen = () => { if (!syncState.isActive && !syncState.error) { updatePendingUploads(); updatePendingObservations(); - checkForUpdates(false); + checkForUpdates(); } }, [ syncState.isActive, 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/service.go b/synkronus/pkg/appbundle/service.go index 5fd17e56b..529d2456c 100644 --- a/synkronus/pkg/appbundle/service.go +++ b/synkronus/pkg/appbundle/service.go @@ -420,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 { @@ -472,7 +472,7 @@ func (s *Service) ensureCurrentVersionSet(ctx context.Context) error { } // GetBundleZipPath returns the filesystem path to the active bundle's zip archive -func (s *Service) GetBundleZipPath(ctx context.Context) (string, error) { +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) { From 35c78c958d60cbb49f2a033ecdad128e0cdfc63f Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 14:30:52 +0300 Subject: [PATCH 08/12] synkronus: relax app bundle validation to allow non-form files under app/forms/ --- synkronus/pkg/appbundle/validation.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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) From 5399a8cdfe32e518083f4aca16d5fe209492fff1 Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 14:43:55 +0300 Subject: [PATCH 09/12] formulus: force WebView remount after app bundle update --- formulus/src/screens/HomeScreen.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/formulus/src/screens/HomeScreen.tsx b/formulus/src/screens/HomeScreen.tsx index bb7bd1ce2..f8fd35dbd 100644 --- a/formulus/src/screens/HomeScreen.tsx +++ b/formulus/src/screens/HomeScreen.tsx @@ -19,6 +19,7 @@ import { appEvents, Listener } from '../webview/FormulusMessageHandlers'; 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( @@ -98,6 +99,7 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { useEffect(() => { const onBundleUpdated: Listener = () => { checkAndSetAppUri(); + setWebViewKey(prev => prev + 1); }; appEvents.addListener('bundleUpdated', onBundleUpdated); return () => appEvents.removeListener('bundleUpdated', onBundleUpdated); @@ -130,6 +132,7 @@ const HomeScreen = ({ navigation }: { navigation: any }) => { /> ) : ( Date: Sun, 8 Feb 2026 15:52:04 +0300 Subject: [PATCH 10/12] formulus: clean up sync screen UI and fix stuck notification --- formulus/src/screens/SyncScreen.tsx | 242 ++++++++++--------- formulus/src/services/NotificationService.ts | 192 +++++---------- 2 files changed, 193 insertions(+), 241 deletions(-) diff --git a/formulus/src/screens/SyncScreen.tsx b/formulus/src/screens/SyncScreen.tsx index 6449bd371..0b6637eae 100644 --- a/formulus/src/screens/SyncScreen.tsx +++ b/formulus/src/screens/SyncScreen.tsx @@ -21,6 +21,8 @@ import { databaseService } from '../database/DatabaseService'; import { getUserInfo } from '../api/synkronus/Auth'; import colors from '../theme/colors'; +type ActiveOperation = 'sync' | 'update' | 'sync_then_update' | null; + const SyncScreen = () => { 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,124 +79,150 @@ 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'); + 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 () => { @@ -214,20 +243,18 @@ 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 @@ -267,7 +294,6 @@ const SyncScreen = () => { updateProgress, ]); - // Smoothly animate progress changes so the bar doesn't "twitch" useEffect(() => { if (!syncState.progress || !syncState.isActive) { Animated.timing(animatedProgress, { @@ -307,13 +333,20 @@ 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...'} - { ]} onPress={handleSync} disabled={syncState.isActive}> - {syncState.isActive ? ( + {isSyncButtonActive ? ( ) : ( )} - {syncState.isActive ? 'Syncing...' : 'Sync Data'} + {isSyncButtonActive ? 'Syncing...' : 'Sync Data'} @@ -539,7 +564,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 )} @@ -749,17 +774,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 99c1453f9..6117de0ba 100644 --- a/formulus/src/services/NotificationService.ts +++ b/formulus/src/services/NotificationService.ts @@ -1,113 +1,105 @@ 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 (_) {} + }, 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, @@ -116,34 +108,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' }, }, }); } @@ -151,81 +135,35 @@ 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' }, }, }); } - async startForegroundService() { - if (Platform.OS !== 'android' || this.foregroundServiceRunning) return; - await this.configure(); - - await notifee.displayNotification({ - id: this.syncNotificationId, - title: 'Syncing data...', - body: 'Sync in progress', - android: { - channelId: this.channelId, - asForegroundService: true, - foregroundServiceTypes: [AndroidForegroundServiceType.DATA_SYNC], - ongoing: true, - progress: { max: 100, current: 0, indeterminate: true }, - }, - }); - this.foregroundServiceRunning = true; - } - - async stopForegroundService() { - if (Platform.OS !== 'android' || !this.foregroundServiceRunning) return; - try { - await notifee.stopForegroundService(); - } catch (e) { - console.warn('Failed to stop foreground service:', e); - } - this.foregroundServiceRunning = false; - } - async hideSyncProgress() { await notifee.cancelNotification(this.syncNotificationId); } 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: From 4320b51c316a4cf7a3f5ba93aab079a0ed1a547f Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 16:14:05 +0300 Subject: [PATCH 11/12] formulus: fix foreground service crash and stuck sync state --- formulus/android/app/src/main/AndroidManifest.xml | 9 ++++++++- formulus/src/services/SyncService.ts | 7 +++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/formulus/android/app/src/main/AndroidManifest.xml b/formulus/android/app/src/main/AndroidManifest.xml index ea263c823..14bd28bae 100644 --- a/formulus/android/app/src/main/AndroidManifest.xml +++ b/formulus/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -45,5 +46,11 @@ + + diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index 4e1df53ef..0697ba9a4 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -174,9 +174,8 @@ export class SyncService { console.warn('Failed to clear stale notifications:', error), ); - await notificationService.startForegroundService(); - try { + await notificationService.startForegroundService(); // Phase 1: Pull - Get manifest and download changes this.updateProgress({ current: 0, @@ -336,9 +335,9 @@ export class SyncService { details: 'Preparing app bundle download...', }); - await notificationService.startForegroundService(); - try { + await notificationService.startForegroundService(); + if (this.shouldCancel) throw new Error('Sync cancelled'); const manifest = await this.withAutoLoginRetry( From 456e23a678660fe92df238bcc89246bad437fa55 Mon Sep 17 00:00:00 2001 From: Najuna Date: Sun, 8 Feb 2026 16:40:38 +0300 Subject: [PATCH 12/12] fix: resolve lint and prettier errors --- formulus/src/screens/SyncScreen.tsx | 12 ++++++++---- formulus/src/services/NotificationService.ts | 4 +++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/formulus/src/screens/SyncScreen.tsx b/formulus/src/screens/SyncScreen.tsx index 0b6637eae..790197a63 100644 --- a/formulus/src/screens/SyncScreen.tsx +++ b/formulus/src/screens/SyncScreen.tsx @@ -246,11 +246,13 @@ const SyncScreen = () => { const getStatusText = (): string => { if (syncState.isActive) { if (activeOperation === 'update') return 'Updating app...'; - if (activeOperation === 'sync_then_update') return 'Syncing & updating...'; + if (activeOperation === 'sync_then_update') + return 'Syncing & updating...'; return 'Syncing...'; } if (syncState.error) return 'Error'; - if (pendingObservations > 0 || pendingUploads.count > 0) return 'Pending sync'; + if (pendingObservations > 0 || pendingUploads.count > 0) + return 'Pending sync'; return 'All synced'; }; @@ -339,8 +341,10 @@ const SyncScreen = () => { return 'Syncing Data'; }; - const isSyncButtonActive = activeOperation === 'sync' || activeOperation === 'sync_then_update'; - const isUpdateButtonActive = activeOperation === 'update' || activeOperation === 'sync_then_update'; + const isSyncButtonActive = + activeOperation === 'sync' || activeOperation === 'sync_then_update'; + const isUpdateButtonActive = + activeOperation === 'update' || activeOperation === 'sync_then_update'; return ( diff --git a/formulus/src/services/NotificationService.ts b/formulus/src/services/NotificationService.ts index 6117de0ba..bfccb1b9a 100644 --- a/formulus/src/services/NotificationService.ts +++ b/formulus/src/services/NotificationService.ts @@ -92,7 +92,9 @@ class NotificationService { setTimeout(async () => { try { await notifee.cancelNotification(this.syncNotificationId); - } catch (_) {} + } catch (_) { + // ignore + } }, 1000); }