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

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.14",
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) {
70
+ const { offlineUser, hasOfflineData } = (await getLastOfflineSession()) || {};
71
+ if (!offlineUser || offlineUser === config.user.email) {
72
+ await sync();
73
+ } else {
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);
67
81
  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
82
  }
79
83
  }
80
- if (saltcorn.data.utils.isPushEnabled(config.user)) {
81
- initPushNotifications();
82
- } else {
83
- await unregisterPushNotifications();
84
- }
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
+ }
@@ -445,57 +445,72 @@ const setSpinnerText = () => {
445
445
  }
446
446
  };
447
447
 
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();
448
+ let syncInProgress = false;
449
+
450
+ export async function isSyncInProgress() {
451
+ return syncInProgress;
452
+ }
453
+
454
+ export async function sync(background = false) {
455
+ if (syncInProgress)
456
+ throw new Error("A synchronization is already in progress.");
457
+
458
+ try {
459
+ if (!background) setSpinnerText();
460
+ const state = saltcorn.data.state.getState();
461
+ const { user } = state.mobileConfig;
462
+ const { offlineUser, hasOfflineData, uploadStarted, uploadStartTime } =
463
+ (await getLastOfflineSession()) || {};
464
+ if (offlineUser && hasOfflineData && offlineUser !== user.email) {
465
+ throw new Error(
466
+ `The sync is not available, '${offlineUser}' has not yet uploaded offline data.`
467
+ );
468
+ } else {
469
+ let syncDir = null;
470
+ let cleanSync = await checkCleanSync(
471
+ uploadStarted,
472
+ uploadStartTime,
473
+ user.email
474
+ );
475
+ const syncTimestamp = await getSyncTimestamp();
476
+ await setUploadStarted(true, syncTimestamp);
477
+ let lock = null;
478
+ if (!background) {
479
+ try {
480
+ if (window.navigator?.wakeLock?.request)
481
+ lock = await window.navigator.wakeLock.request();
482
+ } catch (error) {
483
+ console.log("wakeLock not available");
484
+ console.log(error);
485
+ }
486
+ }
487
+ let transactionOpen = false;
488
+ try {
489
+ await saltcorn.data.db.query("PRAGMA foreign_keys = OFF;");
490
+ await saltcorn.data.db.query("BEGIN");
491
+ transactionOpen = true;
492
+ if (cleanSync) await clearLocalData(true);
493
+ const { synchedTables, syncInfos } = await prepare();
494
+ await syncRemoteDeletes(syncInfos, syncTimestamp);
495
+ syncDir = await syncOfflineData(synchedTables, syncTimestamp);
496
+ await syncRemoteData(syncInfos, syncTimestamp);
497
+ if (!background) await endOfflineMode(true);
498
+ await setUploadStarted(false);
499
+ await saltcorn.data.db.query("COMMIT");
500
+ transactionOpen = false;
501
+ await saltcorn.data.db.query("PRAGMA foreign_keys = ON;");
502
+ } catch (error) {
503
+ if (transactionOpen) await saltcorn.data.db.query("ROLLBACK");
504
+ await saltcorn.data.db.query("PRAGMA foreign_keys = ON;");
505
+ console.log(error);
506
+ throw error;
507
+ } finally {
508
+ if (syncDir) await cleanSyncDir(syncDir);
509
+ if (lock) await lock.release();
510
+ }
498
511
  }
512
+ } finally {
513
+ syncInProgress = false;
499
514
  }
500
515
  }
501
516
 
@@ -602,7 +617,7 @@ export async function hasOfflineRows() {
602
617
  const table = saltcorn.data.models.Table.findOne({ name: tblName });
603
618
  const pkName = table.pk_name;
604
619
  const { rows } = await saltcorn.data.db.query(
605
- `select count(info_tbl.ref)
620
+ `select count(info_tbl.ref) as total
606
621
  from "${saltcorn.data.db.sqlsanitize(
607
622
  tblName
608
623
  )}_sync_info" as info_tbl
@@ -610,7 +625,7 @@ export async function hasOfflineRows() {
610
625
  on info_tbl.ref = data_tbl."${saltcorn.data.db.sqlsanitize(pkName)}"
611
626
  where info_tbl.modified_local = true`
612
627
  );
613
- if (rows?.length > 0 && parseInt(rows[0].count) > 0) return true;
628
+ if (rows?.length > 0 && parseInt(rows[0].total) > 0) return true;
614
629
  }
615
630
  return false;
616
631
  }
@@ -643,3 +658,15 @@ export async function deleteOfflineData(noFeedback) {
643
658
  if (!noFeedback) removeLoadSpinner();
644
659
  }
645
660
  }
661
+
662
+ export function addPushSyncHandler() {
663
+ const state = saltcorn.data.state.getState();
664
+ state.mobile_push_handler["push_sync"] = async (notification) => {
665
+ console.log("Push sync received:", notification);
666
+ try {
667
+ await sync(true);
668
+ } catch (error) {
669
+ console.log("Error during push sync:", error);
670
+ }
671
+ };
672
+ }
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;
@@ -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
- };
@@ -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