diff --git a/app/App.js b/app/App.js
index f076e087f..8ab93033f 100644
--- a/app/App.js
+++ b/app/App.js
@@ -1,16 +1,47 @@
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import NetInfo from '@react-native-community/netinfo';
+import * as Notifications from 'expo-notifications';
+import * as TaskManager from 'expo-task-manager';
+import * as BackgroundFetch from 'expo-background-fetch';
import Navigation from './src/navigation';
import { conn, query, tables } from './src/database';
import { UIState, AuthState, UserState, BuildParamsState } from './src/store';
import { crudUsers, crudConfig } from './src/database/crud';
import { api } from './src/lib';
-import { NetworkStatusBar } from './src/components';
+import { NetworkStatusBar, SyncService } from './src/components';
+import backgroundTask, {
+ SYNC_FORM_SUBMISSION_TASK_NAME,
+ SYNC_FORM_VERSION_TASK_NAME,
+ defineSyncFormSubmissionTask,
+ defineSyncFormVersionTask,
+} from './src/lib/background-task';
const db = conn.init;
+export const setNotificationHandler = () =>
+ Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowAlert: true,
+ shouldPlaySound: true,
+ shouldSetBadge: true,
+ }),
+ });
+
+setNotificationHandler();
+defineSyncFormVersionTask();
+
+TaskManager.defineTask(SYNC_FORM_SUBMISSION_TASK_NAME, async () => {
+ try {
+ await backgroundTask.syncFormSubmission();
+ return BackgroundFetch.BackgroundFetchResult.NewData;
+ } catch (err) {
+ console.error(`[${SYNC_FORM_SUBMISSION_TASK_NAME}] Define task manager failed`, err);
+ return BackgroundFetch.Result.Failed;
+ }
+});
+
const App = () => {
const serverURLState = BuildParamsState.useState((s) => s.serverURL);
const syncValue = BuildParamsState.useState((s) => s.dataSyncInterval);
@@ -64,7 +95,7 @@ const App = () => {
console.info('[CONFIG] Server URL', serverURL);
};
- React.useEffect(() => {
+ useEffect(() => {
const queries = tables.map((t) => {
const queryString = query.initialQuery(t.name, t.fields);
return conn.tx(db, queryString);
@@ -78,7 +109,7 @@ const App = () => {
});
}, []);
- React.useEffect(() => {
+ useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
UIState.update((s) => {
s.online = state.isConnected;
@@ -91,10 +122,30 @@ const App = () => {
};
}, []);
+ const handleOnRegisterTask = useCallback(async () => {
+ try {
+ const allTasks = await TaskManager.getRegisteredTasksAsync();
+ console.log('allTasks', allTasks);
+ allTasks.forEach(async (a) => {
+ if ([SYNC_FORM_SUBMISSION_TASK_NAME, SYNC_FORM_VERSION_TASK_NAME].includes(a.taskName)) {
+ console.info(`[${a.taskName}] IS REGISTERED`);
+ await backgroundTask.registerBackgroundTask(a.taskName);
+ }
+ });
+ } catch (error) {
+ console.error('TASK REGISTER ERROR', error);
+ }
+ }, []);
+
+ useEffect(() => {
+ handleOnRegisterTask();
+ }, [handleOnRegisterTask]);
+
return (
+
);
};
diff --git a/app/package-lock.json b/app/package-lock.json
index 3f4cd5497..7e2fa39c1 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -33,6 +33,7 @@
"pullstate": "^1.25.0",
"react": "18.2.0",
"react-native": "0.71.8",
+ "react-native-background-actions": "^3.0.1",
"react-native-element-dropdown": "^2.9.0",
"react-native-material-menu": "^2.0.0",
"react-native-render-html": "^6.3.4",
@@ -13453,6 +13454,11 @@
"node": ">=6"
}
},
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
+ },
"node_modules/exec-async": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
@@ -23325,6 +23331,21 @@
"react": "18.2.0"
}
},
+ "node_modules/react-native-background-actions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/react-native-background-actions/-/react-native-background-actions-3.0.1.tgz",
+ "integrity": "sha512-xlEjUM2PP+OEQQkr9Z4c4+dRIzhbCh1xAeakL8FKGDib2a3dki2kMR4ZTw+9oHspHn9VF1Fuu78mWXR5zqkVaA==",
+ "dependencies": {
+ "eventemitter3": "^4.0.7"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/Rapsssito"
+ },
+ "peerDependencies": {
+ "react-native": ">=0.47.0"
+ }
+ },
"node_modules/react-native-codegen": {
"version": "0.71.5",
"license": "MIT",
diff --git a/app/package.json b/app/package.json
index 993811f52..ad649cc8a 100644
--- a/app/package.json
+++ b/app/package.json
@@ -32,6 +32,7 @@
"expo-file-system": "~15.2.2",
"expo-image-picker": "~14.1.1",
"expo-location": "~15.1.1",
+ "expo-network": "~5.2.1",
"expo-notifications": "~0.18.1",
"expo-splash-screen": "~0.18.2",
"expo-sqlite": "^11.1.1",
@@ -41,6 +42,7 @@
"pullstate": "^1.25.0",
"react": "18.2.0",
"react-native": "0.71.8",
+ "react-native-background-actions": "^3.0.1",
"react-native-element-dropdown": "^2.9.0",
"react-native-material-menu": "^2.0.0",
"react-native-render-html": "^6.3.4",
@@ -48,8 +50,7 @@
"react-native-screens": "~3.20.0",
"react-native-vector-icons": "^9.2.0",
"react-native-webview": "11.26.0",
- "yup": "^1.2.0",
- "expo-network": "~5.2.1"
+ "yup": "^1.2.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
diff --git a/app/src/components/SyncService.js b/app/src/components/SyncService.js
new file mode 100644
index 000000000..a71c81a5f
--- /dev/null
+++ b/app/src/components/SyncService.js
@@ -0,0 +1,35 @@
+import { useCallback, useEffect } from 'react';
+import { BuildParamsState, UIState } from '../store';
+import { backgroundTask } from '../lib';
+/**
+ * This sync only works in the foreground service
+ */
+const SyncService = () => {
+ const isOnline = UIState.useState((s) => s.online);
+ const syncInterval = BuildParamsState.useState((s) => s.dataSyncInterval);
+ const syncInSecond = parseInt(syncInterval) * 1000;
+
+ const onSync = useCallback(async () => {
+ await backgroundTask.syncFormSubmission();
+ }, []);
+
+ useEffect(() => {
+ if (!syncInSecond || !isOnline) {
+ return;
+ }
+
+ const syncTimer = setInterval(() => {
+ // Perform sync operation
+ onSync();
+ }, syncInSecond);
+
+ return () => {
+ // Clear the interval when the component unmounts
+ clearInterval(syncTimer);
+ };
+ }, [syncInSecond, isOnline, onSync]);
+
+ return null; // This is a service component, no rendering is needed
+};
+
+export default SyncService;
diff --git a/app/src/components/index.js b/app/src/components/index.js
index a1c367504..5bb956bbc 100644
--- a/app/src/components/index.js
+++ b/app/src/components/index.js
@@ -5,3 +5,4 @@ export { default as Image } from './Image';
export { default as CenterLayout } from './CenterLayout';
export { default as LogoutButton } from './LogoutButton';
export { default as NetworkStatusBar } from './NetworkStatusBar';
+export { default as SyncService } from './SyncService';
diff --git a/app/src/lib/background-task.js b/app/src/lib/background-task.js
index 1e7ecd221..d77a5fd4e 100644
--- a/app/src/lib/background-task.js
+++ b/app/src/lib/background-task.js
@@ -34,7 +34,11 @@ const syncFormVersion = async ({
// update previous form latest value to 0
await crudForms.updateForm({ ...form });
console.info('[syncForm]Updated Forms...', form.id);
- const savedForm = await crudForms.addForm({ ...form, formJSON: formRes?.data });
+ const savedForm = await crudForms.addForm({
+ ...form,
+ userId: session?.id,
+ formJSON: formRes?.data,
+ });
console.info('[syncForm]Saved Forms...', form.id);
return savedForm;
});
@@ -51,10 +55,13 @@ const syncFormVersion = async ({
}
};
-const registerBackgroundTask = async (TASK_NAME, minimumInterval = 3600) => {
+const registerBackgroundTask = async (TASK_NAME, settingsValue = null) => {
try {
+ const config = await crudConfig.getConfig();
+ const syncInterval = settingsValue || parseInt(config?.syncInterval) || 3600;
+ console.log('syncInterval', syncInterval);
await BackgroundFetch.registerTaskAsync(TASK_NAME, {
- minimumInterval: minimumInterval,
+ minimumInterval: syncInterval,
stopOnTerminate: false, // android only,
startOnBoot: true, // android only
});
@@ -71,16 +78,10 @@ const unregisterBackgroundTask = async (TASK_NAME) => {
}
};
-const backgroundTaskStatus = async (TASK_NAME, minimumInterval = 3600) => {
+const backgroundTaskStatus = async (TASK_NAME) => {
const status = await BackgroundFetch.getStatusAsync();
const isRegistered = await TaskManager.isTaskRegisteredAsync(TASK_NAME);
- const config = await crudConfig.getConfig();
- const intervalValue = config?.syncInterval || minimumInterval;
-
- if (BackgroundFetch.BackgroundFetchStatus?.[status] === 'Available' && !isRegistered) {
- await registerBackgroundTask(TASK_NAME, intervalValue);
- }
- console.log(`[${TASK_NAME}] Status`, status, isRegistered, minimumInterval);
+ console.info(`[${TASK_NAME}] Status`, status, isRegistered);
};
const handleOnUploadPhotos = async (data) => {
@@ -208,4 +209,35 @@ const backgroundTaskHandler = () => {
};
const backgroundTask = backgroundTaskHandler();
+
+export const SYNC_FORM_VERSION_TASK_NAME = 'sync-form-version';
+
+export const SYNC_FORM_SUBMISSION_TASK_NAME = 'sync-form-submission';
+
+export const defineSyncFormVersionTask = () =>
+ TaskManager.defineTask(SYNC_FORM_VERSION_TASK_NAME, async () => {
+ try {
+ await syncFormVersion({
+ sendPushNotification: notification.sendPushNotification,
+ showNotificationOnly: true,
+ });
+ return BackgroundFetch.BackgroundFetchResult.NewData;
+ } catch (err) {
+ console.error(`[${SYNC_FORM_VERSION_TASK_NAME}] Define task manager failed`, err);
+ return BackgroundFetch.Result.Failed;
+ }
+ });
+
+export const defineSyncFormSubmissionTask = () => {
+ TaskManager.defineTask(SYNC_FORM_SUBMISSION_TASK_NAME, async () => {
+ try {
+ await syncFormSubmission();
+ return BackgroundFetch.BackgroundFetchResult.NewData;
+ } catch (err) {
+ console.error(`[${SYNC_FORM_SUBMISSION_TASK_NAME}] Define task manager failed`, err);
+ return BackgroundFetch.Result.Failed;
+ }
+ });
+};
+
export default backgroundTask;
diff --git a/app/src/navigation/index.js b/app/src/navigation/index.js
index 9031674b7..e1880cdd9 100644
--- a/app/src/navigation/index.js
+++ b/app/src/navigation/index.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import {
@@ -21,57 +21,12 @@ import {
} from '../pages';
import { UIState, AuthState, UserState, FormState, BuildParamsState } from '../store';
import { BackHandler } from 'react-native';
-import * as TaskManager from 'expo-task-manager';
-import * as BackgroundFetch from 'expo-background-fetch';
import * as Notifications from 'expo-notifications';
import { backgroundTask, notification } from '../lib';
-
-const SYNC_FORM_VERSION_TASK_NAME = 'sync-form-version';
-const SYNC_FORM_SUBMISSION_TASK_NAME = 'sync-form-submission';
-
-export const setNotificationHandler = () =>
- Notifications.setNotificationHandler({
- handleNotification: async () => ({
- shouldShowAlert: true,
- shouldPlaySound: true,
- shouldSetBadge: true,
- }),
- });
-setNotificationHandler();
-
-export const defineSyncFormVersionTask = () =>
- TaskManager.defineTask(SYNC_FORM_VERSION_TASK_NAME, async () => {
- try {
- await backgroundTask.syncFormVersion({
- sendPushNotification: notification.sendPushNotification,
- showNotificationOnly: true,
- });
- return BackgroundFetch.BackgroundFetchResult.NewData;
- } catch (err) {
- console.error(`[${SYNC_FORM_VERSION_TASK_NAME}] Define task manager failed`, err);
- return BackgroundFetch.Result.Failed;
- }
- });
-defineSyncFormVersionTask();
-
-let isSyncFormSubmissionTaskDefined = false;
-export const defineSyncFormSubmissionTask = () => {
- if (!isSyncFormSubmissionTaskDefined) {
- TaskManager.defineTask(SYNC_FORM_SUBMISSION_TASK_NAME, async () => {
- try {
- await backgroundTask.syncFormSubmission();
- return BackgroundFetch.BackgroundFetchResult.NewData;
- } catch (err) {
- console.error(`[${SYNC_FORM_SUBMISSION_TASK_NAME}] Define task manager failed`, err);
- return BackgroundFetch.Result.Failed;
- }
- });
- isSyncFormSubmissionTaskDefined = true;
- }
-}
-
-
-defineSyncFormSubmissionTask();
+import {
+ SYNC_FORM_SUBMISSION_TASK_NAME,
+ SYNC_FORM_VERSION_TASK_NAME,
+} from '../lib/background-task';
const Stack = createNativeStackNavigator();
@@ -79,9 +34,8 @@ const RootNavigator = () => {
const preventHardwareBackPressFormPages = ['Home', 'AddUser'];
const currentPage = UIState.useState((s) => s.currentPage);
const token = AuthState.useState((s) => s.token); // user already has session
- const syncInterval = BuildParamsState.useState((s) => s.dataSyncInterval);
- React.useEffect(() => {
+ useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (!token || !preventHardwareBackPressFormPages.includes(currentPage)) {
// Allow navigation if user is not logged in
@@ -93,9 +47,7 @@ const RootNavigator = () => {
return () => backHandler.remove();
}, [token, currentPage]);
- React.useEffect(() => {
- backgroundTask.backgroundTaskStatus(SYNC_FORM_VERSION_TASK_NAME);
-
+ useEffect(() => {
notification.registerForPushNotificationsAsync();
const notificationListener = Notifications.addNotificationReceivedListener((notification) => {
// console.info('[Notification]Received Listener');
@@ -103,7 +55,6 @@ const RootNavigator = () => {
const responseListener = Notifications.addNotificationResponseReceivedListener((res) => {
const notificationBody = res?.notification?.request;
const notificationType = notificationBody?.content?.data?.notificationType;
- // console.log('[Notification]Response Listener', notificationBody);
if (notificationType === 'sync-form-version') {
backgroundTask.syncFormVersion({ showNotificationOnly: false });
}
@@ -117,9 +68,10 @@ const RootNavigator = () => {
};
}, []);
- React.useEffect(() => {
- backgroundTask.backgroundTaskStatus(SYNC_FORM_SUBMISSION_TASK_NAME, syncInterval);
- }, [syncInterval]);
+ useEffect(() => {
+ backgroundTask.backgroundTaskStatus(SYNC_FORM_VERSION_TASK_NAME);
+ backgroundTask.backgroundTaskStatus(SYNC_FORM_SUBMISSION_TASK_NAME);
+ }, []);
return (
diff --git a/app/src/pages/Settings.js b/app/src/pages/Settings.js
index 70f055071..3c5c4411a 100644
--- a/app/src/pages/Settings.js
+++ b/app/src/pages/Settings.js
@@ -1,12 +1,13 @@
import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
-import { ListItem, Divider, Button } from '@rneui/themed';
+import { ListItem, Divider, Button, Switch } from '@rneui/themed';
+import BackgroundService from 'react-native-background-actions';
import { BaseLayout, LogoutButton } from '../components';
import DialogForm from './Settings/DialogForm';
import { config, langConfig } from './Settings/config';
import { UIState, FormState, BuildParamsState } from '../store';
-import { i18n } from '../lib';
+import { backgroundTask, i18n } from '../lib';
const Settings = ({ navigation }) => {
const [showLang, setShowLang] = useState(false);
@@ -39,6 +40,33 @@ const Settings = ({ navigation }) => {
navigation.navigate('FormSelection');
};
+ const handleOnBackgroundTask = async (isActive = true) => {
+ try {
+ if (isActive) {
+ const taskDesc = 'Sync data submission';
+ const options = {
+ taskName: SYNC_FORM_SUBMISSION_TASK_NAME,
+ taskTitle: SYNC_FORM_SUBMISSION_TASK_NAME,
+ taskDesc,
+ taskIcon: {
+ name: 'ic_launcher',
+ type: 'mipmap',
+ },
+ parameters: {
+ delay: 3000,
+ },
+ };
+ const res = await BackgroundService.start(backgroundTask.syncFormSubmission, options);
+ console.log('ress', res);
+ await BackgroundService.updateNotification({ taskDesc });
+ } else {
+ await BackgroundService.stop();
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
return (
@@ -96,6 +124,13 @@ const Settings = ({ navigation }) => {
+ {/*
+
+ Auto sync
+ This will be running in the background
+
+
+ */}
);
diff --git a/app/src/pages/Settings/SettingsForm.js b/app/src/pages/Settings/SettingsForm.js
index 6d89bea81..1587efe80 100644
--- a/app/src/pages/Settings/SettingsForm.js
+++ b/app/src/pages/Settings/SettingsForm.js
@@ -82,6 +82,7 @@ const SettingsForm = ({ route }) => {
}
if (configFields.includes('syncInterval')) {
await backgroundTask.unregisterBackgroundTask('sync-form-submission');
+ await backgroundTask.registerBackgroundTask('sync-form-submission', parseInt(value));
BuildParamsState.update((s) => {
s.dataSyncInterval = value;
});
diff --git a/backend/source/forms/1699354006503.prod.json b/backend/source/forms/1699354006503.prod.json
index 0260db422..4995e8c99 100644
--- a/backend/source/forms/1699354006503.prod.json
+++ b/backend/source/forms/1699354006503.prod.json
@@ -330,10 +330,22 @@
"options": null,
"fn": null
},
+ {
+ "id": 1699951887044,
+ "meta": false,
+ "question": "Implementation partners working in community",
+ "name": null,
+ "order": 4,
+ "required": true,
+ "type": "cascade",
+ "api": {
+ "endpoint": "/api/v1/organisations?attributes=2"
+ }
+ },
{
"id": 1699951986776,
"question": "Soil type in community",
- "order": 4,
+ "order": 5,
"type": "option",
"tooltip": {
"text": "INTERVIEW: what is the main soil type in this community?
Soil type: what type of soil makes up the top 6 feet of the soil profile (only one type can be selected)
Soil drainage: ask whether water soaks into the soil? If water soaks in quickly and easily, then it is likely to be a high drainage soil; if it soaks in at a normal rate, it is a medium drainage soil; and if water soaks in very slowly (or not at all) then it is a low drainage soil"
@@ -362,7 +374,7 @@
{
"id": 1699952153243,
"question": "Depth to groundwater in community",
- "order": 5,
+ "order": 6,
"type": "option",
"tooltip": {
"text": "INTERVIEW: what is the average depth to the groundwater table in this community?
Depth to groundwater: how many metres below ground is water found (e.g. depth from ground level to water in wells and boreholes)?"
@@ -391,7 +403,7 @@
{
"id": 1699952219561,
"question": "Water supply protection in community",
- "order": 6,
+ "order": 7,
"type": "option",
"tooltip": {
"text": "INTERVIEW: what is the main water supply technology used in this community?
Water supply technology: main system or facilities used to supply drinking water"
@@ -420,7 +432,7 @@
{
"id": 1699952295823,
"question": "Use of groundwater for drinking water supply",
- "order": 7,
+ "order": 8,
"type": "option",
"tooltip": {
"text": "INTERVIEW: what percentage of households obtain drinking water from groundwater sources in this community?
Obtained from groundwater sources: drinking water is collected from water supplies or water points that produce water from below ground (boreholes, dug wells or springs)"
@@ -449,10 +461,10 @@
{
"id": 1699952355848,
"question": "% of toilets less than 10m from groundwater sources",
- "order": 8,
+ "order": 9,
"type": "option",
"tooltip": {
- "text": "INTERVIEW: what percentage of household toilets are located less than 10m from groundwater sources?
Toilet distance from groundwater source:the horizontal distance (along the ground) between the toilet and the nearest groundwater source (borehole, dug well or spring)"
+ "text": "INTERVIEW: what percentage of household toilets are located less than 10m from groundwater sources?
Toilet distance from groundwater source:the horizontal distance (along the ground) between the toilet and the nearest groundwater source (borehole, dug well or spring)"
},
"required": true,
"meta": false,
@@ -473,7 +485,7 @@
{
"id": 1699952418978,
"question": "Toilets uphill from groundwater sources",
- "order": 9,
+ "order": 10,
"type": "option",
"tooltip": {
"text": "INTERVIEW: what percentage of household toilets are located uphill from (above) the nearest groundwater sources?
Toilet uphill of groundwater source: the toilet is located at a higher point than (above) the groundwater source (borehole, dug well or spring)"
@@ -497,7 +509,7 @@
{
"id": 1702364544574,
"question": "COMMUNITY LEVEL ASSESSMENT: Low risk of groundwater contamination",
- "order": 10,
+ "order": 11,
"type": "option",
"tooltip": null,
"required": true,
@@ -623,10 +635,39 @@
"options": null,
"fn": null
},
+ {
+ "id": 1702324544574,
+ "question": "COMMUNITY LEVEL ASSESSMENT: Monitoring of at-risk households",
+ "order": 7,
+ "type": "option",
+ "tooltip": null,
+ "required": true,
+ "meta": false,
+ "options": [
+ {
+ "id": 16999223328041,
+ "name": "G0 At-risk household monitoring: no disaggregated data available from community",
+ "order": 1,
+ "color": "#ffc7ce"
+ },
+ {
+ "id": 16999524328042,
+ "name": "G0 At-risk household monitoring not reliable: More than 5% difference in monitoring data",
+ "order": 2,
+ "color": "#ffc7ce"
+ },
+ {
+ "id": 16999524328852,
+ "name": "G1/G2/G3 At-risk household monitoring system functional: Less than 5% difference in monitoring data",
+ "order": 3,
+ "color": "#c6efce"
+ }
+ ]
+ },
{
"id": 1699958670124,
"question": "Action plan for G2 status",
- "order": 7,
+ "order": 8,
"type": "option",
"tooltip": {
"text": "INTERVIEW: has an action plan for G2 status been approved?
INTERVIEW: is the action plan for G2 status in use?"
@@ -652,7 +693,7 @@
{
"id": 1699958822791,
"question": "Action plan for G3 status",
- "order": 8,
+ "order": 9,
"type": "option",
"tooltip": {
"text": "INTERVIEW: has an action plan for G3 status been approved?
INTERVIEW: is the action plan for G3 status in use?"