Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions formulus/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

Expand All @@ -16,11 +17,13 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

<!-- Notification permissions for react-native-push-notification -->
<!-- Notification and foreground service permissions -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<application
android:name=".MainApplication"
Expand All @@ -43,5 +46,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name="app.notifee.core.ForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync"
tools:replace="android:foregroundServiceType" />
</application>
</manifest>
9 changes: 8 additions & 1 deletion formulus/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
13 changes: 12 additions & 1 deletion formulus/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion formulus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 75 additions & 0 deletions formulus/src/api/synkronus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
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[] {
Expand Down
2 changes: 2 additions & 0 deletions formulus/src/contexts/SyncContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, {
useCallback,
ReactNode,
} from 'react';
import { syncService as syncServiceInstance } from '../services/SyncService';

export interface SyncProgress {
current: number;
Expand Down Expand Up @@ -70,6 +71,7 @@ export const SyncProvider: React.FC<SyncProviderProps> = ({ children }) => {
}, []);

const cancelSync = useCallback(() => {
syncServiceInstance.cancelSync();
setSyncState(prev => ({
...prev,
isActive: false,
Expand Down
12 changes: 12 additions & 0 deletions formulus/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [webViewKey, setWebViewKey] = useState(0);
const customAppRef = useRef<CustomAppWebViewHandle>(null);

useFocusEffect(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -121,6 +132,7 @@ const HomeScreen = ({ navigation }: { navigation: any }) => {
/>
) : (
<CustomAppWebView
key={webViewKey}
ref={customAppRef}
appUrl={localUri}
appName="custom_app"
Expand Down
Loading
Loading