@saltcorn/mobile-app 1.5.0-beta.13 → 1.5.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ import { BackgroundFetch } from "@transistorsoft/capacitor-background-fetch";
2
+ import { sync } from "./offline_mode.js";
3
+
4
+ let isConfigured = false;
5
+
6
+ /**
7
+ * Init the periodic background sync with a min interval.
8
+ * This runs the sync even if the app is in background or was swipe closed.
9
+ * If no internet connection is available it fails silently.
10
+ * @param {number} interval min time interval in minutes. The system decides when to actually do it
11
+ * @returns {Promise<boolean>} True if configuration was successful, false otherwise.
12
+ */
13
+ export async function startPeriodicBackgroundSync(interval = 15) {
14
+ if (isConfigured) {
15
+ console.log("Background sync is already configured. Skipping.");
16
+ return true;
17
+ }
18
+
19
+ console.log("Configuring background sync with interval (minutes):", interval);
20
+
21
+ const status = await BackgroundFetch.configure(
22
+ {
23
+ minimumFetchInterval: interval,
24
+ },
25
+ async (taskId) => {
26
+ console.log(
27
+ "Starting background sync:",
28
+ taskId,
29
+ new Date().toISOString()
30
+ );
31
+ await sync(true);
32
+ console.log(
33
+ "Background sync finished:",
34
+ taskId,
35
+ new Date().toISOString()
36
+ );
37
+ BackgroundFetch.finish(taskId);
38
+ },
39
+ async (taskId) => {
40
+ console.log("[BackgroundFetch] TIMEOUT:", taskId);
41
+ BackgroundFetch.finish(taskId);
42
+ }
43
+ );
44
+
45
+ if (status === BackgroundFetch.STATUS_AVAILABLE) {
46
+ console.log("Background sync successfully configured.");
47
+ isConfigured = true; // Set the flag only on success
48
+ return true;
49
+ } else {
50
+ // Handle error statuses
51
+ if (status === BackgroundFetch.STATUS_DENIED) {
52
+ console.log(
53
+ "The user explicitly disabled background behavior for this app or for the whole system."
54
+ );
55
+ } else if (status === BackgroundFetch.STATUS_RESTRICTED) {
56
+ console.log(
57
+ "Background updates are unavailable and the user cannot enable them again."
58
+ );
59
+ }
60
+ isConfigured = false;
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Stops/Unregisters the periodic background sync.
67
+ * This should be called when the user logs out.
68
+ */
69
+ export async function stopPeriodicBackgroundSync() {
70
+ if (!isConfigured) {
71
+ console.log("Background sync is not currently configured. Skipping stop.");
72
+ return;
73
+ }
74
+
75
+ console.log("Stopping background sync");
76
+ try {
77
+ await BackgroundFetch.stop();
78
+ console.log("Background sync successfully stopped.");
79
+ isConfigured = false;
80
+ } catch (error) {
81
+ console.error("Error stopping background sync:", error);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Check if the background sync is currently configured/enabled.
87
+ * @returns {boolean} True if the sync is configured, false otherwise.
88
+ */
89
+ export function isBackgroundSyncActive() {
90
+ return isConfigured;
91
+ }
@@ -0,0 +1,172 @@
1
+ /*global saltcorn*/
2
+
3
+ import { Capacitor } from "@capacitor/core";
4
+ import { apiCall } from "./api";
5
+ import { showAlerts } from "./common";
6
+ import { PushNotifications } from "@capacitor/push-notifications";
7
+ import { Device } from "@capacitor/device";
8
+
9
+ /**
10
+ * internal helper to subscribe or unsubscribe to push notifications (server side)
11
+ */
12
+ async function notifyTokenApi(config, isSubscribe) {
13
+ console.log("notifyTokenApi subscribe:", isSubscribe);
14
+ const { token, deviceId } = config.pushConfiguration;
15
+ try {
16
+ const response = await apiCall({
17
+ method: "POST",
18
+ path: `/notifications/mobile-${isSubscribe ? "subscribe" : "remove-subscription"}`,
19
+ body: { token, deviceId },
20
+ });
21
+ const data = response.data;
22
+ if (data.success === "ok")
23
+ console.log(
24
+ `successfully ${isSubscribe ? "subscribed" : "unsubscribed"} to push notifications`,
25
+ data
26
+ );
27
+ else
28
+ console.error(
29
+ `unable to ${isSubscribe ? "subscribe" : "unsubscribe"} to push notifications`,
30
+ data
31
+ );
32
+ } catch (error) {
33
+ console.error(
34
+ `unable to ${isSubscribe ? "subscribe" : "unsubscribe"} to push notifications`,
35
+ error
36
+ );
37
+ }
38
+ }
39
+
40
+ /**
41
+ * internal helper to subscribe or unsubscribe to push sync (server side)
42
+ * @param {*} config
43
+ * @param {*} isSubscribe
44
+ */
45
+ async function syncTokenApi(config, isSubscribe) {
46
+ console.log("syncTokenApi subscribe:", isSubscribe);
47
+ const { token, deviceId } = config.pushConfiguration;
48
+ try {
49
+ const response = await apiCall({
50
+ method: "POST",
51
+ path: `/sync/push_${isSubscribe ? "subscribe" : "unsubscribe"}`,
52
+ body: { token, deviceId, synchedTables: config.synchedTables },
53
+ });
54
+ const data = response.data;
55
+ if (data.success === "ok")
56
+ console.log(
57
+ `successfully ${isSubscribe ? "subscribed" : "unsubscribed"} to push sync`,
58
+ data
59
+ );
60
+ else
61
+ console.error(
62
+ `unable to ${isSubscribe ? "subscribe" : "unsubscribe"} to push sync`,
63
+ data
64
+ );
65
+ } catch (error) {
66
+ console.error(
67
+ `unable to ${isSubscribe ? "subscribe" : "unsubscribe"} to push sync`,
68
+ error
69
+ );
70
+ }
71
+ }
72
+
73
+ async function messageHandler(notification) {
74
+ console.log("Push received:", notification);
75
+ const state = saltcorn.data.state.getState();
76
+ const type = notification.data?.type;
77
+ if (type && state.mobile_push_handler?.[type]) {
78
+ try {
79
+ await state.mobile_push_handler[type](notification);
80
+ } catch (error) {
81
+ console.error(`Error handling '${type}' push notification:`, error);
82
+ }
83
+ }
84
+ }
85
+
86
+ let registrationListener = null;
87
+ let registrationErrorListener = null;
88
+ let pushReceivedListener = null;
89
+
90
+ export async function initPushNotifications() {
91
+ if (Capacitor.getPlatform() !== "web" && PushNotifications) {
92
+ await removePushListeners();
93
+ const permStatus = await PushNotifications.requestPermissions();
94
+ if (permStatus.receive === "granted") {
95
+ await PushNotifications.register();
96
+ registrationListener = PushNotifications.addListener(
97
+ "registration",
98
+ async (token) => {
99
+ console.log("Push registration success, token:", token.value);
100
+ const config = saltcorn.data.state.getState().mobileConfig;
101
+ if (config.pushConfiguration) {
102
+ console.log("Push already registered");
103
+ } else {
104
+ const { identifier } = await Device.getId();
105
+ config.pushConfiguration = {
106
+ token: token.value,
107
+ deviceId: identifier,
108
+ };
109
+ await notifyTokenApi(config, true);
110
+ if (config.allowOfflineMode && config.pushSync)
111
+ await syncTokenApi(config, true);
112
+ }
113
+ }
114
+ );
115
+
116
+ registrationErrorListener = PushNotifications.addListener(
117
+ "registrationError",
118
+ (err) => {
119
+ console.error("Push registration error:", err);
120
+ }
121
+ );
122
+
123
+ pushReceivedListener = PushNotifications.addListener(
124
+ "pushNotificationReceived",
125
+ messageHandler
126
+ );
127
+ } else {
128
+ console.warn("Push notification permission not granted");
129
+ }
130
+ }
131
+ }
132
+
133
+ export async function unregisterPushNotifications() {
134
+ if (Capacitor.getPlatform() !== "web" && PushNotifications) {
135
+ try {
136
+ await PushNotifications.unregister();
137
+ const config = saltcorn.data.state.getState().mobileConfig;
138
+ await notifyTokenApi(config, false);
139
+ if (config.allowOfflineMode && config.pushSync)
140
+ await syncTokenApi(config, false);
141
+ await removePushListeners();
142
+ config.pushConfiguration = null;
143
+ console.log("Push notifications unregistered successfully");
144
+ } catch (error) {
145
+ console.error("Error unregistering push notifications:", error);
146
+ }
147
+ }
148
+ }
149
+
150
+ export function addPusNotifyHandler() {
151
+ const state = saltcorn.data.state.getState();
152
+ state.mobile_push_handler["push_notification"] = (notification) => {
153
+ console.log("Push notification received:", notification);
154
+ showAlerts([
155
+ {
156
+ type: "info",
157
+ msg: notification.body,
158
+ title: notification.title,
159
+ },
160
+ ]);
161
+ };
162
+ }
163
+
164
+ async function removePushListeners() {
165
+ await registrationListener?.remove();
166
+ await registrationErrorListener?.remove();
167
+ await pushReceivedListener?.remove();
168
+
169
+ registrationListener = null;
170
+ registrationErrorListener = null;
171
+ pushReceivedListener = null;
172
+ }
package/package.json CHANGED
@@ -1,14 +1,12 @@
1
1
  {
2
2
  "name": "@saltcorn/mobile-app",
3
3
  "displayName": "Saltcorn mobile app",
4
- "version": "1.5.0-beta.13",
4
+ "version": "1.5.0-beta.15",
5
5
  "description": "Saltcorn mobile app for Android and iOS",
6
6
  "main": "index.js",
7
7
  "scripts": {
8
8
  "build": "webpack",
9
9
  "add-platform": "npx cap add",
10
- "modify-gradle-cfg": "node build_scripts/modify_gradle_cfg.js",
11
- "modify-android-manifest": "node build_scripts/modify_android_manifest.js",
12
10
  "test": "echo NO TESTS"
13
11
  },
14
12
  "author": "Christian Hugo",
@@ -5,12 +5,14 @@ import i18next from "i18next";
5
5
  import { apiCall } from "./api";
6
6
  import { router } from "../routing/index";
7
7
  import { getLastOfflineSession, deleteOfflineData, sync } from "./offline_mode";
8
- import { addRoute, replaceIframe } from "../helpers/navigation";
9
- import { showAlerts } from "./common";
8
+ import { addRoute, replaceIframe, clearHistory } from "../helpers/navigation";
10
9
  import {
11
- initPushNotifications,
12
- unregisterPushNotifications,
13
- } from "../helpers/notifications";
10
+ showAlerts,
11
+ tryInitBackgroundSync,
12
+ tryInitPush,
13
+ tryStopBackgroundSync,
14
+ tryUnregisterPush,
15
+ } from "./common";
14
16
 
15
17
  /**
16
18
  * internal helper for the normal login/signup and public login
@@ -45,78 +47,96 @@ async function loginRequest({ email, password, isSignup, isPublic }) {
45
47
  }
46
48
 
47
49
  /**
48
- * helper for normal logins and auth provider logins
49
- * @param {string} token
50
+ * internal helper to process a JWT token
51
+ * @param {string} tokenStr
50
52
  */
51
- export async function handleToken(token) {
52
- const decodedJwt = jwtDecode(token);
53
- const state = saltcorn.data.state.getState();
54
- const config = state.mobileConfig;
55
- config.user = decodedJwt.user;
53
+ const handleToken = async (tokenStr, config) => {
54
+ const token = jwtDecode(tokenStr);
55
+ const user = token.user;
56
+ config.user = user;
56
57
  config.isPublicUser = false;
57
58
  config.isOfflineMode = false;
58
- await insertUser(config.user);
59
- await setJwt(token);
60
- config.jwt = token;
61
- i18next.changeLanguage(config.user.language);
59
+ await insertUser(user);
60
+ await setJwt(tokenStr);
61
+ config.jwt = tokenStr;
62
+ i18next.changeLanguage(user.language);
63
+ };
64
+
65
+ /**
66
+ * internal helper to run the first sync
67
+ */
68
+ const initialSync = async (config) => {
62
69
  const alerts = [];
63
- if (config.allowOfflineMode) {
64
- const { offlineUser, hasOfflineData } =
65
- (await getLastOfflineSession()) || {};
66
- if (!offlineUser || offlineUser === config.user.email) {
67
- await sync();
68
- } else {
69
- if (hasOfflineData)
70
- alerts.push({
71
- type: "warning",
72
- msg: `'${offlineUser}' has not yet uploaded offline data.`,
73
- });
74
- else {
75
- await deleteOfflineData(true);
76
- await sync();
77
- }
78
- }
79
- }
80
- if (saltcorn.data.utils.isPushEnabled(config.user)) {
81
- initPushNotifications();
70
+ const { offlineUser, hasOfflineData } = (await getLastOfflineSession()) || {};
71
+ if (!offlineUser || offlineUser === config.user.email) {
72
+ await sync(false, alerts);
82
73
  } else {
83
- await unregisterPushNotifications();
74
+ if (hasOfflineData)
75
+ alerts.push({
76
+ type: "warning",
77
+ msg: `'${offlineUser}' has not yet uploaded offline data.`,
78
+ });
79
+ else {
80
+ await deleteOfflineData(true);
81
+ await sync(false, alerts);
82
+ }
84
83
  }
85
- alerts.push({
86
- type: "success",
87
- msg: i18next.t("Welcome, %s!", {
88
- postProcess: "sprintf",
89
- sprintf: [config.user.email],
90
- }),
91
- });
84
+ return alerts;
85
+ };
92
86
 
87
+ /**
88
+ * internal helper to get the path to the first page
89
+ */
90
+ const getEntryPoint = (config) => {
93
91
  let entryPoint = null;
94
92
  if (config.entryPointType === "byrole") {
93
+ const state = saltcorn.data.state.getState();
95
94
  const homepageByRole = state.getConfig("home_page_by_role", {})[
96
95
  config.user.role_id
97
96
  ];
98
97
  if (homepageByRole) entryPoint = `get/page/${homepageByRole}`;
99
98
  else throw new Error("No homepage defined for this role.");
100
99
  } else entryPoint = config.entry_point;
100
+ return entryPoint;
101
+ };
101
102
 
102
- addRoute({ route: entryPoint, query: undefined });
103
- const page = await router.resolve({
104
- pathname: entryPoint,
105
- fullWrap: true,
106
- alerts,
107
- });
108
- if (page.content) await replaceIframe(page.content, page.isFile);
109
- }
110
-
111
- export async function login({ email, password, isSignup }) {
112
- const loginResult = await loginRequest({
113
- email,
114
- password,
115
- isSignup,
116
- });
103
+ /**
104
+ * For normal login/signup email and password are used
105
+ * When called from auth provider login (see google-auth plugin), token is used
106
+ * @param {*} param0
107
+ */
108
+ export async function login({ email, password, isSignup, token }) {
109
+ const loginResult = !token
110
+ ? await loginRequest({
111
+ email,
112
+ password,
113
+ isSignup,
114
+ })
115
+ : token;
117
116
  if (typeof loginResult === "string") {
118
- // use it as a token
119
- await handleToken(loginResult);
117
+ const alerts = [];
118
+ const config = saltcorn.data.state.getState().mobileConfig;
119
+ await handleToken(loginResult, config);
120
+ if (config.allowOfflineMode) alerts.push(await initialSync(config));
121
+ await tryInitPush(config);
122
+ await tryInitBackgroundSync(config);
123
+ alerts.push({
124
+ type: "success",
125
+ msg: i18next.t("Welcome, %s!", {
126
+ postProcess: "sprintf",
127
+ sprintf: [config.user.email],
128
+ }),
129
+ });
130
+
131
+ // open first page
132
+ const entryPoint = getEntryPoint(config);
133
+ addRoute({ route: entryPoint, query: undefined });
134
+ const page = await router.resolve({
135
+ pathname: entryPoint,
136
+ fullWrap: true,
137
+ alerts,
138
+ });
139
+ if (page.content) await replaceIframe(page.content, page.isFile);
120
140
  } else if (loginResult?.alerts) {
121
141
  showAlerts(loginResult?.alerts);
122
142
  } else {
@@ -179,13 +199,22 @@ export async function publicLogin(entryPoint) {
179
199
  export async function logout() {
180
200
  try {
181
201
  const config = saltcorn.data.state.getState().mobileConfig;
182
- const page = await router.resolve({
183
- pathname: "get/auth/logout",
184
- entryView: config.entry_point,
185
- versionTag: config.version_tag,
186
- });
187
- await replaceIframe(page.content);
202
+ await tryUnregisterPush();
203
+ await tryStopBackgroundSync();
204
+ const response = await apiCall({ method: "GET", path: "/auth/logout" });
205
+ if (response.data.success) {
206
+ await removeJwt();
207
+ clearHistory();
208
+ config.jwt = undefined;
209
+ const page = await router.resolve({
210
+ pathname: "get/auth/login",
211
+ entryView: config.entry_point,
212
+ versionTag: config.version_tag,
213
+ });
214
+ await replaceIframe(page.content);
215
+ } else throw new Error("Unable to logout.");
188
216
  } catch (error) {
217
+ console.error("unable to logout:", error);
189
218
  showAlerts([
190
219
  {
191
220
  type: "error",
@@ -4,6 +4,7 @@ import { apiCall } from "./api";
4
4
  import { Camera, CameraResultType } from "@capacitor/camera";
5
5
  import { ScreenOrientation } from "@capacitor/screen-orientation";
6
6
  import { SendIntent } from "send-intent";
7
+ import { addPushSyncHandler } from "./offline_mode";
7
8
 
8
9
  const orientationChangeListeners = new Set();
9
10
 
@@ -189,3 +190,78 @@ export async function checkSendIntentReceived() {
189
190
  return null;
190
191
  }
191
192
  }
193
+
194
+ /**
195
+ * init the push system, if available
196
+ */
197
+ export async function tryInitPush(config) {
198
+ try {
199
+ const { initPushNotifications, addPusNotifyHandler } = await import(
200
+ "../helpers/notifications.js"
201
+ );
202
+ try {
203
+ await initPushNotifications();
204
+ if (saltcorn.data.utils.isPushEnabled(config.user)) addPusNotifyHandler();
205
+ if (config.pushSync) addPushSyncHandler();
206
+ } catch (error) {
207
+ console.error("Error initializing push notifications:", error);
208
+ }
209
+ } catch (error) {
210
+ console.log("Push notifications module not available:", error);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * init background sync, if available
216
+ */
217
+ export async function tryInitBackgroundSync(config) {
218
+ try {
219
+ const { startPeriodicBackgroundSync } = await import(
220
+ "../helpers/background_sync.js"
221
+ );
222
+ try {
223
+ if (config.syncInterval && config.syncInterval > 0)
224
+ await startPeriodicBackgroundSync(config.syncInterval);
225
+ } catch (error) {
226
+ console.error("Error initializing background sync:", error);
227
+ }
228
+ } catch (error) {
229
+ console.log("Background sync module not available:", error);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * end the push system, if available
235
+ */
236
+ export async function tryUnregisterPush() {
237
+ try {
238
+ const { unregisterPushNotifications } = await import(
239
+ "../helpers/notifications.js"
240
+ );
241
+ try {
242
+ await unregisterPushNotifications();
243
+ } catch (error) {
244
+ console.error("Error unregistering push notifications:", error);
245
+ }
246
+ } catch (error) {
247
+ console.log("Push notifications module not available:", error);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * stop background sync, if available
253
+ */
254
+ export async function tryStopBackgroundSync() {
255
+ try {
256
+ const { stopPeriodicBackgroundSync } = await import(
257
+ "../helpers/background_sync.js"
258
+ );
259
+ try {
260
+ await stopPeriodicBackgroundSync();
261
+ } catch (error) {
262
+ console.error("Error stopping periodic background sync:", error);
263
+ }
264
+ } catch (error) {
265
+ console.error("Push notifications module not available:", error);
266
+ }
267
+ }
@@ -318,6 +318,27 @@ const handleUniqueConflicts = async (uniqueConflicts, translatedIds) => {
318
318
  }
319
319
  };
320
320
 
321
+ const handleUpdateConflicts = async (dataConflicts, alerts) => {
322
+ let hasConflicts = false;
323
+ for (const [tblName, updates] of Object.entries(dataConflicts)) {
324
+ const table = saltcorn.data.models.Table.findOne({ name: tblName });
325
+ const pkName = table.pk_name || "id";
326
+ for (const update of updates) {
327
+ const { [pkName]: _sc_pkValue, ...rest } = update;
328
+ await table.updateRow(rest, _sc_pkValue);
329
+ hasConflicts = true;
330
+ }
331
+ }
332
+ if (hasConflicts) {
333
+ alerts.push({
334
+ type: "info",
335
+ msg:
336
+ "Some of your changes could not be applied because the data has changed on the server. " +
337
+ "Your local data has been updated accordingly.",
338
+ });
339
+ }
340
+ };
341
+
321
342
  const updateSyncInfos = async (
322
343
  offlineChanges,
323
344
  allTranslations,
@@ -357,7 +378,7 @@ const updateSyncInfos = async (
357
378
  }
358
379
  };
359
380
 
360
- const syncOfflineData = async (synchedTables, syncTimestamp) => {
381
+ const syncOfflineData = async (synchedTables, syncTimestamp, alerts) => {
361
382
  const offlineChanges = await loadOfflineChanges(synchedTables);
362
383
  if (Object.keys(offlineChanges).length === 0) return null;
363
384
  const uploadResp = await apiCall({
@@ -365,7 +386,8 @@ const syncOfflineData = async (synchedTables, syncTimestamp) => {
365
386
  path: "/sync/offline_changes",
366
387
  body: {
367
388
  changes: offlineChanges,
368
- syncTimestamp,
389
+ oldSyncTimestamp: await getLocalSyncTimestamp(),
390
+ newSyncTimestamp: syncTimestamp,
369
391
  },
370
392
  });
371
393
  const { syncDir } = uploadResp.data;
@@ -377,10 +399,12 @@ const syncOfflineData = async (synchedTables, syncTimestamp) => {
377
399
  path: `/sync/upload_finished?dir_name=${encodeURIComponent(syncDir)}`,
378
400
  });
379
401
  pollCount++;
380
- const { finished, translatedIds, uniqueConflicts, error } = pollResp.data;
402
+ const { finished, translatedIds, uniqueConflicts, dataConflicts, error } =
403
+ pollResp.data;
381
404
  if (finished) {
382
405
  if (error) throw new Error(error.message);
383
406
  else {
407
+ await handleUpdateConflicts(dataConflicts, alerts);
384
408
  await handleUniqueConflicts(uniqueConflicts, translatedIds);
385
409
  await handleTranslatedIds(uniqueConflicts, translatedIds);
386
410
  await updateSyncInfos(offlineChanges, translatedIds, syncTimestamp);
@@ -425,7 +449,7 @@ const checkCleanSync = async (uploadStarted, uploadStartTime, userName) => {
425
449
  return false;
426
450
  };
427
451
 
428
- const getSyncTimestamp = async () => {
452
+ const getServerTime = async () => {
429
453
  const resp = await apiCall({
430
454
  method: "GET",
431
455
  path: `/sync/sync_timestamp`,
@@ -433,6 +457,16 @@ const getSyncTimestamp = async () => {
433
457
  return resp.data.syncTimestamp;
434
458
  };
435
459
 
460
+ const setLocalSyncTimestamp = async (syncTimestamp) => {
461
+ const state = saltcorn.data.state.getState();
462
+ await state.setConfig("mobile_sync_timestamp", syncTimestamp);
463
+ };
464
+
465
+ const getLocalSyncTimestamp = async () => {
466
+ const state = saltcorn.data.state.getState();
467
+ return await state.getConfig("mobile_sync_timestamp");
468
+ };
469
+
436
470
  const setSpinnerText = () => {
437
471
  const iframeWindow = $("#content-iframe")[0].contentWindow;
438
472
  if (iframeWindow) {
@@ -445,57 +479,73 @@ const setSpinnerText = () => {
445
479
  }
446
480
  };
447
481
 
448
- export async function sync() {
449
- setSpinnerText();
450
- const state = saltcorn.data.state.getState();
451
- const { user } = state.mobileConfig;
452
- const { offlineUser, hasOfflineData, uploadStarted, uploadStartTime } =
453
- (await getLastOfflineSession()) || {};
454
- if (offlineUser && hasOfflineData && offlineUser !== user.email) {
455
- throw new Error(
456
- `The sync is not available, '${offlineUser}' has not yet uploaded offline data.`
457
- );
458
- } else {
459
- let syncDir = null;
460
- let cleanSync = await checkCleanSync(
461
- uploadStarted,
462
- uploadStartTime,
463
- user.email
464
- );
465
- const syncTimestamp = await getSyncTimestamp();
466
- await setUploadStarted(true, syncTimestamp);
467
- let lock = null;
468
- try {
469
- if (window.navigator?.wakeLock?.request)
470
- lock = await window.navigator.wakeLock.request();
471
- } catch (error) {
472
- console.log("wakeLock not available");
473
- console.log(error);
474
- }
475
- let transactionOpen = false;
476
- try {
477
- await saltcorn.data.db.query("PRAGMA foreign_keys = OFF;");
478
- await saltcorn.data.db.query("BEGIN");
479
- transactionOpen = true;
480
- if (cleanSync) await clearLocalData(true);
481
- const { synchedTables, syncInfos } = await prepare();
482
- await syncRemoteDeletes(syncInfos, syncTimestamp);
483
- syncDir = await syncOfflineData(synchedTables, syncTimestamp);
484
- await syncRemoteData(syncInfos, syncTimestamp);
485
- await endOfflineMode(true);
486
- await setUploadStarted(false);
487
- await saltcorn.data.db.query("COMMIT");
488
- transactionOpen = false;
489
- await saltcorn.data.db.query("PRAGMA foreign_keys = ON;");
490
- } catch (error) {
491
- if (transactionOpen) await saltcorn.data.db.query("ROLLBACK");
492
- await saltcorn.data.db.query("PRAGMA foreign_keys = ON;");
493
- console.log(error);
494
- throw error;
495
- } finally {
496
- if (syncDir) await cleanSyncDir(syncDir);
497
- if (lock) await lock.release();
482
+ let syncInProgress = false;
483
+
484
+ export async function isSyncInProgress() {
485
+ return syncInProgress;
486
+ }
487
+
488
+ export async function sync(background = false, alerts = []) {
489
+ if (syncInProgress)
490
+ throw new Error("A synchronization is already in progress.");
491
+
492
+ try {
493
+ if (!background) setSpinnerText();
494
+ const state = saltcorn.data.state.getState();
495
+ const { user } = state.mobileConfig;
496
+ const { offlineUser, hasOfflineData, uploadStarted, uploadStartTime } =
497
+ (await getLastOfflineSession()) || {};
498
+ if (offlineUser && hasOfflineData && offlineUser !== user.email) {
499
+ throw new Error(
500
+ `The sync is not available, '${offlineUser}' has not yet uploaded offline data.`
501
+ );
502
+ } else {
503
+ let syncDir = null;
504
+ let cleanSync = await checkCleanSync(
505
+ uploadStarted,
506
+ uploadStartTime,
507
+ user.email
508
+ );
509
+ const syncTimestamp = await getServerTime();
510
+ await setUploadStarted(true, syncTimestamp);
511
+ let lock = null;
512
+ if (!background) {
513
+ try {
514
+ if (window.navigator?.wakeLock?.request)
515
+ lock = await window.navigator.wakeLock.request();
516
+ } catch (error) {
517
+ console.log("wakeLock not available");
518
+ console.log(error);
519
+ }
520
+ }
521
+ let transactionOpen = false;
522
+ try {
523
+ await saltcorn.data.db.query("PRAGMA foreign_keys = OFF;");
524
+ await saltcorn.data.db.query("BEGIN");
525
+ transactionOpen = true;
526
+ if (cleanSync) await clearLocalData(true);
527
+ const { synchedTables, syncInfos } = await prepare();
528
+ await syncRemoteDeletes(syncInfos, syncTimestamp);
529
+ syncDir = await syncOfflineData(synchedTables, syncTimestamp, alerts);
530
+ await syncRemoteData(syncInfos, syncTimestamp);
531
+ await setLocalSyncTimestamp(syncTimestamp);
532
+ if (!background) await endOfflineMode(true);
533
+ await setUploadStarted(false);
534
+ await saltcorn.data.db.query("COMMIT");
535
+ transactionOpen = false;
536
+ await saltcorn.data.db.query("PRAGMA foreign_keys = ON;");
537
+ } catch (error) {
538
+ if (transactionOpen) await saltcorn.data.db.query("ROLLBACK");
539
+ await saltcorn.data.db.query("PRAGMA foreign_keys = ON;");
540
+ console.log(error);
541
+ throw error;
542
+ } finally {
543
+ if (syncDir) await cleanSyncDir(syncDir);
544
+ if (lock) await lock.release();
545
+ }
498
546
  }
547
+ } finally {
548
+ syncInProgress = false;
499
549
  }
500
550
  }
501
551
 
@@ -602,7 +652,7 @@ export async function hasOfflineRows() {
602
652
  const table = saltcorn.data.models.Table.findOne({ name: tblName });
603
653
  const pkName = table.pk_name;
604
654
  const { rows } = await saltcorn.data.db.query(
605
- `select count(info_tbl.ref)
655
+ `select count(info_tbl.ref) as total
606
656
  from "${saltcorn.data.db.sqlsanitize(
607
657
  tblName
608
658
  )}_sync_info" as info_tbl
@@ -610,7 +660,7 @@ export async function hasOfflineRows() {
610
660
  on info_tbl.ref = data_tbl."${saltcorn.data.db.sqlsanitize(pkName)}"
611
661
  where info_tbl.modified_local = true`
612
662
  );
613
- if (rows?.length > 0 && parseInt(rows[0].count) > 0) return true;
663
+ if (rows?.length > 0 && parseInt(rows[0].total) > 0) return true;
614
664
  }
615
665
  return false;
616
666
  }
@@ -643,3 +693,17 @@ export async function deleteOfflineData(noFeedback) {
643
693
  if (!noFeedback) removeLoadSpinner();
644
694
  }
645
695
  }
696
+
697
+ export function addPushSyncHandler() {
698
+ const state = saltcorn.data.state.getState();
699
+ state.mobile_push_handler["push_sync"] = async (notification) => {
700
+ console.log("Push sync received:", notification);
701
+ const alerts = [];
702
+ try {
703
+ await sync(true, alerts);
704
+ if (alerts.length > 0) showAlerts(alerts);
705
+ } catch (error) {
706
+ console.log("Error during push sync:", error);
707
+ }
708
+ };
709
+ }
package/src/index.js CHANGED
@@ -2,13 +2,32 @@ import { init } from "./init";
2
2
  import * as api from "./helpers/api";
3
3
  import * as auth from "./helpers/auth";
4
4
  import * as common from "./helpers/common";
5
- import * as notifications from "./helpers/notifications";
6
5
  import * as fileSystem from "./helpers/file_system";
7
6
  import * as navigation from "./helpers/navigation";
8
7
  import * as offlineMode from "./helpers/offline_mode";
9
8
  import * as dbSchema from "./helpers/db_schema";
10
9
  import { router } from "./routing/index";
11
10
 
11
+ // included when push notifications or push sync is enabled
12
+ let notifications = undefined;
13
+ try {
14
+ notifications = require("./helpers/notifications");
15
+ console.log("Notifications module available");
16
+ } catch (err) {
17
+ console.log("Notifications module not available");
18
+ }
19
+
20
+ // included when periodic background sync is enabled
21
+ let backgroundSync = undefined;
22
+ try {
23
+ backgroundSync = require("./helpers/background_sync");
24
+ console.log("Background sync module available");
25
+ }
26
+ catch (err) {
27
+ console.log("Background sync module not available");
28
+ }
29
+
30
+ // include code placed in a mobile-app directory inside plugins
12
31
  const plugins = {};
13
32
  const context = require.context("./plugins-code", true, /index\.js$/);
14
33
  context.keys().forEach((key) => {
@@ -22,10 +41,11 @@ export const mobileApp = {
22
41
  api,
23
42
  auth,
24
43
  common,
25
- notifications,
26
44
  fileSystem,
27
45
  navigation: { ...navigation, router },
28
46
  offlineMode,
29
47
  dbSchema,
48
+ ...(notifications ? { notifications } : {}),
49
+ ...(backgroundSync ? { backgroundSync } : {}),
30
50
  plugins,
31
51
  };
package/src/init.js CHANGED
@@ -22,7 +22,11 @@ import {
22
22
  gotoEntryView,
23
23
  addRoute,
24
24
  } from "./helpers/navigation.js";
25
- import { checkSendIntentReceived } from "./helpers/common.js";
25
+ import {
26
+ checkSendIntentReceived,
27
+ tryInitBackgroundSync,
28
+ tryInitPush,
29
+ } from "./helpers/common.js";
26
30
  import { readJSONCordova, readTextCordova } from "./helpers/file_system.js";
27
31
 
28
32
  import i18next from "i18next";
@@ -443,6 +447,7 @@ export async function init(mobileConfig) {
443
447
  const jwt = state.mobileConfig.jwt;
444
448
  const alerts = [];
445
449
  if ((networkDisabled && jwt) || (await checkJWT(jwt))) {
450
+ // already logged in, continue
446
451
  const mobileConfig = state.mobileConfig;
447
452
  const decodedJwt = jwtDecode(mobileConfig.jwt);
448
453
  mobileConfig.user = decodedJwt.user;
@@ -467,7 +472,7 @@ export async function init(mobileConfig) {
467
472
  }
468
473
  } else if (offlineUser) {
469
474
  if (offlineUser === mobileConfig.user.email) {
470
- await sync();
475
+ await sync(false, alerts);
471
476
  alerts.push({
472
477
  type: "info",
473
478
  msg: "Synchronized your offline data.",
@@ -478,7 +483,7 @@ export async function init(mobileConfig) {
478
483
  msg: `'${offlineUser}' has not yet uploaded offline data.`,
479
484
  });
480
485
  } else {
481
- await sync();
486
+ await sync(false, alerts);
482
487
  alerts.push({
483
488
  type: "info",
484
489
  msg: "Synchronized your offline data.",
@@ -486,6 +491,9 @@ export async function init(mobileConfig) {
486
491
  }
487
492
  }
488
493
 
494
+ await tryInitPush(mobileConfig);
495
+ await tryInitBackgroundSync(mobileConfig);
496
+
489
497
  if (Capacitor.getPlatform() === "ios") {
490
498
  const shareData = await checkSendIntentReceived();
491
499
  if (shareData && notEmpty(shareData)) return await postShare(shareData);
@@ -518,6 +526,7 @@ export async function init(mobileConfig) {
518
526
  }
519
527
  if (page.content) await replaceIframe(page.content, page.isFile);
520
528
  } else if (isPublicJwt(jwt)) {
529
+ // already logged in as public
521
530
  const config = state.mobileConfig;
522
531
  config.user = { role_id: 100, email: "public", language: "en" };
523
532
  config.isPublicUser = true;
@@ -539,13 +548,17 @@ export async function init(mobileConfig) {
539
548
  )) &&
540
549
  state.mobileConfig.autoPublicLogin
541
550
  ) {
551
+ // try autoPublicLogin
542
552
  if (networkDisabled)
543
553
  throw new Error(
544
554
  "No internet connection or previous login is available. " +
545
555
  "Please go online and reload, the public login is not yet supported."
546
556
  );
547
557
  await publicLogin(getEntryPoint(100, state, state.mobileConfig));
548
- } else await showLogin(alerts);
558
+ } else {
559
+ // open login page
560
+ await showLogin(alerts);
561
+ }
549
562
  } catch (error) {
550
563
  if (typeof saltcorn === "undefined" || typeof router === "undefined") {
551
564
  const msg = `An error occured: ${
@@ -6,7 +6,7 @@ import {
6
6
  updateTableRow,
7
7
  insertTableRow,
8
8
  } from "./routes/api";
9
- import { getLoginView, logoutAction, getSignupView } from "./routes/auth";
9
+ import { getLoginView, getSignupView } from "./routes/auth";
10
10
  import { deleteRows } from "./routes/delete";
11
11
  import { postToggleField } from "./routes/edit";
12
12
  import { getErrorView } from "./routes/error";
@@ -54,16 +54,12 @@ const routes = [
54
54
  path: "get/auth/login",
55
55
  action: getLoginView,
56
56
  },
57
- {
58
- path: "get/auth/logout",
59
- action: logoutAction,
60
- },
61
57
  {
62
58
  path: "get/auth/signup",
63
59
  action: getSignupView,
64
60
  },
61
+
65
62
  // delete
66
-
67
63
  {
68
64
  path: "post/delete/:tableName/:id", // legacy
69
65
  action: deleteRows,
@@ -72,7 +68,7 @@ const routes = [
72
68
  path: "delete/api/:tableName/:id",
73
69
  action: deleteRows,
74
70
  },
75
-
71
+
76
72
  // edit
77
73
  {
78
74
  path: "post/edit/toggle/:name/:id/:field_name",
@@ -1,11 +1,12 @@
1
1
  /*global saltcorn */
2
2
  import { MobileRequest } from "../mocks/request";
3
3
  import { MobileResponse } from "../mocks/response";
4
- import { apiCall } from "../../helpers/api";
5
- import { removeJwt } from "../../helpers/auth";
6
4
  import { sbAdmin2Layout, getHeaders } from "../utils";
7
- import { clearHistory } from "../../helpers/navigation";
8
5
 
6
+ /**
7
+ * internal helper to prepare the login or signup form
8
+ * @returns
9
+ */
9
10
  const prepareAuthForm = () => {
10
11
  return new saltcorn.data.models.Form({
11
12
  class: "login",
@@ -29,7 +30,13 @@ const prepareAuthForm = () => {
29
30
  });
30
31
  };
31
32
 
32
- // TODO delete this and integrate getAuthLinks() from '/server/auth/routes.js'
33
+ /**
34
+ * internal helper to get auth links
35
+ * TODO delete this and integrate getAuthLinks() from '/server/auth/routes.js'
36
+ * @param {*} current
37
+ * @param {*} entryPoint
38
+ * @returns
39
+ */
33
40
  const getAuthLinks = (current, entryPoint) => {
34
41
  const links = { methods: [] };
35
42
  const state = saltcorn.data.state.getState();
@@ -51,6 +58,13 @@ const getAuthLinks = (current, entryPoint) => {
51
58
  return links;
52
59
  };
53
60
 
61
+ /**
62
+ * internal helper to render login view
63
+ * @param {*} entryPoint
64
+ * @param {*} versionTag
65
+ * @param {*} alerts
66
+ * @returns
67
+ */
54
68
  const renderLoginView = async (entryPoint, versionTag, alerts = []) => {
55
69
  const state = saltcorn.data.state.getState();
56
70
  const form = prepareAuthForm(entryPoint);
@@ -101,6 +115,12 @@ const renderLoginView = async (entryPoint, versionTag, alerts = []) => {
101
115
  });
102
116
  };
103
117
 
118
+ /**
119
+ * internal helper to render signup view
120
+ * @param {*} entryPoint
121
+ * @param {*} versionTag
122
+ * @returns
123
+ */
104
124
  const renderSignupView = (entryPoint, versionTag) => {
105
125
  const form = prepareAuthForm(entryPoint);
106
126
  form.onSubmit = `javascript:signupFormSubmit(this, '${entryPoint}')`;
@@ -146,23 +166,3 @@ export const getSignupView = async () => {
146
166
  replaceIframe: true,
147
167
  };
148
168
  };
149
-
150
- /**
151
- *
152
- * @returns
153
- */
154
- export const logoutAction = async () => {
155
- const config = saltcorn.data.state.getState().mobileConfig;
156
- const response = await apiCall({ method: "GET", path: "/auth/logout" });
157
- if (response.data.success) {
158
- await removeJwt();
159
- clearHistory();
160
- config.jwt = undefined;
161
- return {
162
- content: await renderLoginView(config.entry_point, config.version_tag),
163
- };
164
- } else {
165
- console.log("unable to logout");
166
- return {};
167
- }
168
- };
@@ -892,15 +892,15 @@ async function callSync() {
892
892
  } else {
893
893
  const wasOffline = mobileConfig.isOfflineMode;
894
894
  showLoadSpinner();
895
- await parent.saltcorn.mobileApp.offlineMode.sync();
895
+ const alerts = [];
896
+ await parent.saltcorn.mobileApp.offlineMode.sync(false, alerts);
896
897
  parent.saltcorn.mobileApp.common.clearAlerts();
897
898
  if (!wasOffline) {
898
- parent.saltcorn.mobileApp.common.showAlerts([
899
- {
900
- type: "info",
901
- msg: "Synchronized your offline data.",
902
- },
903
- ]);
899
+ alerts.push({
900
+ type: "info",
901
+ msg: "Synchronized your offline data.",
902
+ });
903
+ parent.saltcorn.mobileApp.common.showAlerts(alerts);
904
904
  } else {
905
905
  setNetworSwitcherOn();
906
906
  parent.saltcorn.mobileApp.navigation.clearHistory();
@@ -908,12 +908,11 @@ async function callSync() {
908
908
  parent.saltcorn.mobileApp.navigation.addRoute({
909
909
  route: "get/sync/sync_settings",
910
910
  });
911
- parent.saltcorn.mobileApp.common.showAlerts([
912
- {
913
- type: "info",
914
- msg: "Synchronized your offline data, you are online again.",
915
- },
916
- ]);
911
+ alerts.push({
912
+ type: "info",
913
+ msg: "Synchronized your offline data, you are online again.",
914
+ });
915
+ parent.saltcorn.mobileApp.common.showAlerts(alerts);
917
916
  parent.saltcorn.mobileApp.common.clearTopAlerts();
918
917
  }
919
918
  }
@@ -1,53 +0,0 @@
1
- const { parseStringPromise, Builder } = require("xml2js");
2
- const { join } = require("path");
3
- const { readFileSync, writeFileSync } = require("fs");
4
-
5
- const readMobileConfig = () => {
6
- console.log("Reading mobile config");
7
- const content = readFileSync(
8
- "/saltcorn-mobile-app/saltcorn-mobile-cfg.json",
9
- "utf8"
10
- );
11
- console.log(content);
12
- return JSON.parse(content);
13
- };
14
-
15
- (async () => {
16
- try {
17
- const { permissions, features } = readMobileConfig();
18
- const androidManifest = join(
19
- "android",
20
- "app",
21
- "src",
22
- "main",
23
- "AndroidManifest.xml"
24
- );
25
- const content = readFileSync(androidManifest);
26
- const parsed = await parseStringPromise(content);
27
-
28
- parsed.manifest["uses-permission"] = permissions.map((p) => ({
29
- $: { "android:name": p },
30
- }));
31
- parsed.manifest["uses-feature"] = features.map((f) => ({
32
- $: { "android:name": f },
33
- }));
34
-
35
- parsed.manifest.application[0].$ = {
36
- ...parsed.manifest.application[0].$,
37
- "android:allowBackup": "false",
38
- "android:fullBackupContent": "false",
39
- "android:dataExtractionRules": "@xml/data_extraction_rules",
40
- "android:networkSecurityConfig": "@xml/network_security_config",
41
- "android:usesCleartextTraffic": "true",
42
- };
43
- const xmlBuilder = new Builder();
44
- const newCfg = xmlBuilder.buildObject(parsed);
45
- writeFileSync(androidManifest, newCfg);
46
- } catch (error) {
47
- console.log(
48
- `Unable to modify the AndroidManifest.xml: ${
49
- error.message ? error.message : "Unknown error"
50
- }`
51
- );
52
- }
53
- })();
@@ -1,21 +0,0 @@
1
- const { join } = require("path");
2
- const { readFileSync, writeFileSync } = require("fs");
3
-
4
- console.log("Writing gradle config");
5
- console.log("args", process.argv);
6
- const args = process.argv.slice(2);
7
- const appVersion = args[0].split("=")[1];
8
-
9
- const gradleFile = join(__dirname, "..", "android", "app", "build.gradle");
10
- const gradleContent = readFileSync(gradleFile, "utf8");
11
-
12
- // generate versionCode from appVersion
13
- const parts = appVersion.split(".");
14
- const versionCode =
15
- parseInt(parts[0]) * 1000000 + parseInt(parts[1]) * 1000 + parseInt(parts[2]);
16
- let newGradleContent = gradleContent
17
- .replace(/versionName "1.0"/, `versionName "${appVersion}"`)
18
- .replace(/versionCode 1/, `versionCode ${versionCode}`);
19
-
20
- console.log("newGradleContent", newGradleContent);
21
- writeFileSync(gradleFile, newGradleContent, "utf8");
@@ -1,92 +0,0 @@
1
- import { Capacitor } from "@capacitor/core";
2
- import { apiCall } from "./api";
3
- import { showAlerts } from "./common";
4
-
5
- /**
6
- * @capacitor/push-notifications isn't always included in the build
7
- */
8
- async function loadNotificationsPlugin() {
9
- try {
10
- const { PushNotifications } = await import("@capacitor/push-notifications");
11
- return PushNotifications;
12
- } catch (error) {
13
- console.warn("Error loading PushNotifications plugin:", error);
14
- return null;
15
- }
16
- }
17
-
18
- /**
19
- * @capacitor/device isn't always included in the build
20
- */
21
- async function loadDevicePlugin() {
22
- try {
23
- const { Device } = await import("@capacitor/device");
24
- return Device;
25
- } catch (error) {
26
- console.warn("Error loading Device plugin:", error);
27
- return null;
28
- }
29
- }
30
-
31
- async function uploadFcmToken(token, deviceId) {
32
- try {
33
- const response = await apiCall({
34
- method: "POST",
35
- path: "/notifications/fcm-token",
36
- body: { token, deviceId },
37
- });
38
- const data = response.data;
39
- if (data.success.success === "ok")
40
- console.log("Token uploaded successfully:", data);
41
- else console.error("Unable to upload token:", data);
42
- } catch (error) {
43
- console.error("Error uploading token:", error);
44
- }
45
- }
46
-
47
- export async function initPushNotifications() {
48
- const PushNotifications = await loadNotificationsPlugin();
49
- if (Capacitor.getPlatform() !== "web" && PushNotifications) {
50
- const { Device } = await loadDevicePlugin();
51
- const permStatus = await PushNotifications.requestPermissions();
52
- if (permStatus.receive === "granted") {
53
- await PushNotifications.register();
54
- PushNotifications.addListener("registration", async (token) => {
55
- const { identifier } = await Device.getId();
56
- await uploadFcmToken(token.value, identifier);
57
- });
58
-
59
- PushNotifications.addListener("registrationError", (err) => {
60
- console.error("Push registration error:", err);
61
- });
62
-
63
- PushNotifications.addListener(
64
- "pushNotificationReceived",
65
- (notification) => {
66
- console.log("Push received in foreground:", notification);
67
- showAlerts([
68
- {
69
- type: "info",
70
- msg: notification.body,
71
- title: notification.title,
72
- },
73
- ]);
74
- }
75
- );
76
- } else {
77
- console.warn("Push notification permission not granted");
78
- }
79
- }
80
- }
81
-
82
- export async function unregisterPushNotifications() {
83
- const PushNotifications = await loadNotificationsPlugin();
84
- if (Capacitor.getPlatform() !== "web" && PushNotifications) {
85
- try {
86
- await PushNotifications.unregister();
87
- console.log("Push notifications unregistered successfully");
88
- } catch (error) {
89
- console.error("Error unregistering push notifications:", error);
90
- }
91
- }
92
- }
File without changes