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 }) => {