@webority/mobile-core 0.0.26 → 0.0.28
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.
- package/README.md +69 -1
- package/lib/commonjs/api/retry.js +17 -1
- package/lib/commonjs/api/userClient.js +1 -1
- package/lib/commonjs/auth/authStore.js +49 -16
- package/lib/commonjs/config/index.js +8 -2
- package/lib/commonjs/formatters/currency.js +2 -1
- package/lib/commonjs/formatters/date.js +5 -6
- package/lib/commonjs/formatters/dateMath.js +2 -1
- package/lib/commonjs/formatters/intlCache.js +37 -0
- package/lib/commonjs/formatters/number.js +4 -3
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/initSDK.js +1 -1
- package/lib/commonjs/network/networkStatus.js +40 -2
- package/lib/commonjs/push/index.js +18 -0
- package/lib/commonjs/push/push.js +177 -15
- package/lib/commonjs/push/task.js +43 -0
- package/lib/commonjs/utils/base64.js +24 -0
- package/lib/module/api/retry.js +17 -1
- package/lib/module/api/userClient.js +2 -2
- package/lib/module/auth/authStore.js +49 -16
- package/lib/module/config/index.js +7 -1
- package/lib/module/formatters/currency.js +2 -1
- package/lib/module/formatters/date.js +5 -6
- package/lib/module/formatters/dateMath.js +2 -1
- package/lib/module/formatters/intlCache.js +30 -0
- package/lib/module/formatters/number.js +4 -3
- package/lib/module/index.js +2 -0
- package/lib/module/initSDK.js +2 -2
- package/lib/module/network/networkStatus.js +37 -0
- package/lib/module/push/index.js +1 -1
- package/lib/module/push/push.js +170 -15
- package/lib/module/push/task.js +38 -0
- package/lib/module/utils/base64.js +19 -0
- package/lib/typescript/commonjs/config/index.d.ts +6 -0
- package/lib/typescript/commonjs/formatters/intlCache.d.ts +6 -0
- package/lib/typescript/commonjs/index.d.ts +1 -0
- package/lib/typescript/commonjs/initSDK.d.ts +1 -1
- package/lib/typescript/commonjs/network/networkStatus.d.ts +6 -0
- package/lib/typescript/commonjs/push/index.d.ts +2 -2
- package/lib/typescript/commonjs/push/push.d.ts +88 -0
- package/lib/typescript/commonjs/push/task.d.ts +15 -0
- package/lib/typescript/commonjs/utils/base64.d.ts +7 -0
- package/lib/typescript/module/config/index.d.ts +6 -0
- package/lib/typescript/module/formatters/intlCache.d.ts +6 -0
- package/lib/typescript/module/index.d.ts +1 -0
- package/lib/typescript/module/initSDK.d.ts +1 -1
- package/lib/typescript/module/network/networkStatus.d.ts +6 -0
- package/lib/typescript/module/push/index.d.ts +2 -2
- package/lib/typescript/module/push/push.d.ts +88 -0
- package/lib/typescript/module/push/task.d.ts +15 -0
- package/lib/typescript/module/utils/base64.d.ts +7 -0
- package/package.json +15 -1
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.usePush = exports.setPushImplementation = exports.setBadgeCount = exports.initPush = exports.enablePush = exports.disablePush = void 0;
|
|
6
|
+
exports.usePush = exports.toPushAction = exports.toNotificationActions = exports.setPushImplementation = exports.setCategories = exports.setBadgeCount = exports.routeTaskPayload = exports.resolveModules = exports.onAction = exports.notifyLocal = exports.initPush = exports.enablePush = exports.disablePush = void 0;
|
|
7
7
|
var _react = require("react");
|
|
8
8
|
var _reactNative = require("react-native");
|
|
9
9
|
var _index = require("../logger/index.js");
|
|
10
|
+
/** A button on a notification category. */
|
|
11
|
+
|
|
12
|
+
/** A set of buttons a push names through its category id. */
|
|
13
|
+
|
|
14
|
+
/** A button tap on a notification. */
|
|
15
|
+
|
|
10
16
|
/** The slice of expo-notifications this module calls, so the optional peer stays untyped at build. */
|
|
11
17
|
|
|
12
18
|
let override = null;
|
|
@@ -26,7 +32,7 @@ const setPushImplementation = impl => {
|
|
|
26
32
|
/**
|
|
27
33
|
* The only place expo-notifications and expo-application are named. Expo Go is
|
|
28
34
|
* checked first because expo-notifications refuses remote push there and its
|
|
29
|
-
* import throws on Android.
|
|
35
|
+
* import throws on Android. Exported for `./task`; not on the `/push` entry.
|
|
30
36
|
*/
|
|
31
37
|
exports.setPushImplementation = setPushImplementation;
|
|
32
38
|
const resolveModules = () => {
|
|
@@ -54,6 +60,7 @@ const resolveModules = () => {
|
|
|
54
60
|
}
|
|
55
61
|
return resolved;
|
|
56
62
|
};
|
|
63
|
+
exports.resolveModules = resolveModules;
|
|
57
64
|
let state = {
|
|
58
65
|
status: 'unknown',
|
|
59
66
|
enabled: false
|
|
@@ -71,7 +78,18 @@ const setState = next => {
|
|
|
71
78
|
let config = null;
|
|
72
79
|
let channelsReady = Promise.resolve();
|
|
73
80
|
let tokenSubscription = null;
|
|
74
|
-
let
|
|
81
|
+
let actionHandler = null;
|
|
82
|
+
let listening = false;
|
|
83
|
+
/** Responses that arrived before anything could handle them, e.g. the launching tap before initPush. */
|
|
84
|
+
const pendingResponses = [];
|
|
85
|
+
/** Response key to when it was routed. */
|
|
86
|
+
const routedAt = new Map();
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The cold-start read, the live listener and, on Android, the background task
|
|
90
|
+
* can each report the same response a moment apart.
|
|
91
|
+
*/
|
|
92
|
+
const REPEAT_WINDOW_MS = 10_000;
|
|
75
93
|
const requireConfig = () => {
|
|
76
94
|
if (!config) {
|
|
77
95
|
throw new Error('[@webority/mobile-core] call initPush before enablePush or disablePush.');
|
|
@@ -96,22 +114,112 @@ const toStringData = data => {
|
|
|
96
114
|
}
|
|
97
115
|
return out;
|
|
98
116
|
};
|
|
99
|
-
const
|
|
100
|
-
|
|
117
|
+
const responseData = response => {
|
|
118
|
+
const content = response.notification.request.content;
|
|
119
|
+
if (content.data) {
|
|
120
|
+
return toStringData(content.data);
|
|
121
|
+
}
|
|
122
|
+
if (typeof content.dataString === 'string') {
|
|
123
|
+
try {
|
|
124
|
+
return toStringData(JSON.parse(content.dataString));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
_index.Logger.error('[push] notification data is not JSON', error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {};
|
|
130
|
+
};
|
|
131
|
+
const toPushAction = response => ({
|
|
132
|
+
actionId: response.actionIdentifier,
|
|
133
|
+
...(response.userText ? {
|
|
134
|
+
userText: response.userText
|
|
135
|
+
} : {}),
|
|
136
|
+
data: responseData(response),
|
|
137
|
+
notificationId: response.notification.request.identifier
|
|
138
|
+
});
|
|
139
|
+
exports.toPushAction = toPushAction;
|
|
140
|
+
const toNotificationActions = actions => actions.map(action => ({
|
|
141
|
+
identifier: action.id,
|
|
142
|
+
buttonTitle: action.title,
|
|
143
|
+
...(action.textInput ? {
|
|
144
|
+
textInput: {
|
|
145
|
+
submitButtonTitle: action.textInput.submitTitle,
|
|
146
|
+
placeholder: action.textInput.placeholder
|
|
147
|
+
}
|
|
148
|
+
} : {}),
|
|
149
|
+
options: {
|
|
150
|
+
opensAppToForeground: action.opensApp ?? true,
|
|
151
|
+
isDestructive: action.destructive ?? false,
|
|
152
|
+
isAuthenticationRequired: action.authenticationRequired ?? false
|
|
153
|
+
}
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sends a button tap to the action handler and a plain tap to `onOpen`. A
|
|
158
|
+
* button tap with no action handler opens like a plain tap. Resolves once the
|
|
159
|
+
* handler has finished, so a background task stays alive for its request.
|
|
160
|
+
*/
|
|
161
|
+
exports.toNotificationActions = toNotificationActions;
|
|
162
|
+
const routeResponse = async (m, response) => {
|
|
163
|
+
const key = `${response.notification.request.identifier}:${response.actionIdentifier}`;
|
|
164
|
+
const last = routedAt.get(key);
|
|
165
|
+
if (last !== undefined && Date.now() - last < REPEAT_WINDOW_MS) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const isAction = response.actionIdentifier !== m.DEFAULT_ACTION_IDENTIFIER;
|
|
169
|
+
if (isAction && actionHandler) {
|
|
170
|
+
routedAt.set(key, Date.now());
|
|
171
|
+
try {
|
|
172
|
+
await actionHandler(toPushAction(response));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
_index.Logger.error('[push] onAction threw', error);
|
|
175
|
+
}
|
|
101
176
|
return;
|
|
102
177
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (id === lastOpenedId) {
|
|
178
|
+
if (!config) {
|
|
179
|
+
pendingResponses.push(response);
|
|
106
180
|
return;
|
|
107
181
|
}
|
|
108
|
-
|
|
182
|
+
routedAt.set(key, Date.now());
|
|
109
183
|
try {
|
|
110
|
-
config.onOpen(
|
|
184
|
+
config.onOpen(responseData(response));
|
|
111
185
|
} catch (error) {
|
|
112
186
|
_index.Logger.error('[push] onOpen threw', error);
|
|
113
187
|
}
|
|
114
188
|
};
|
|
189
|
+
const drainPending = async m => {
|
|
190
|
+
const queued = pendingResponses.splice(0);
|
|
191
|
+
for (const response of queued) {
|
|
192
|
+
await routeResponse(m, response);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Follows taps and button taps, including the one that launched the app. Wired once per process. */
|
|
197
|
+
const listenForResponses = m => {
|
|
198
|
+
if (listening) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
listening = true;
|
|
202
|
+
m.addNotificationResponseReceivedListener(response => {
|
|
203
|
+
void routeResponse(m, response);
|
|
204
|
+
});
|
|
205
|
+
void m.getLastNotificationResponseAsync().then(async response => {
|
|
206
|
+
if (response) {
|
|
207
|
+
await routeResponse(m, response);
|
|
208
|
+
}
|
|
209
|
+
return m.clearLastNotificationResponseAsync?.();
|
|
210
|
+
}).catch(error => _index.Logger.error('[push] reading the launch notification failed', error));
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** The payload of the background notification task: routes button taps, ignores received pushes. */
|
|
214
|
+
const routeTaskPayload = async payload => {
|
|
215
|
+
const mods = resolveModules();
|
|
216
|
+
const response = payload;
|
|
217
|
+
if (!mods || typeof response?.actionIdentifier !== 'string' || typeof response.notification?.request?.identifier !== 'string') {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await routeResponse(mods.notifications, response);
|
|
221
|
+
};
|
|
222
|
+
exports.routeTaskPayload = routeTaskPayload;
|
|
115
223
|
const createChannels = async (m, channels) => {
|
|
116
224
|
for (const channel of channels) {
|
|
117
225
|
try {
|
|
@@ -174,11 +282,8 @@ const initPush = next => {
|
|
|
174
282
|
if (_reactNative.Platform.OS === 'android' && next.androidChannels?.length) {
|
|
175
283
|
channelsReady = createChannels(m, next.androidChannels);
|
|
176
284
|
}
|
|
177
|
-
m
|
|
178
|
-
void m
|
|
179
|
-
handleResponse(response);
|
|
180
|
-
return m.clearLastNotificationResponseAsync?.();
|
|
181
|
-
}).catch(error => _index.Logger.error('[push] reading the launch notification failed', error));
|
|
285
|
+
listenForResponses(m);
|
|
286
|
+
void drainPending(m);
|
|
182
287
|
void m.getPermissionsAsync().then(response => setState({
|
|
183
288
|
status: toPermissionStatus(response)
|
|
184
289
|
})).catch(error => _index.Logger.error('[push] permission check failed', error));
|
|
@@ -256,7 +361,64 @@ const setBadgeCount = async count => {
|
|
|
256
361
|
}
|
|
257
362
|
await mods.notifications.setBadgeCountAsync(count);
|
|
258
363
|
};
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Registers the button sets a push can name by category id. Call it at every
|
|
367
|
+
* start, with the same ids on both platforms. On Android the buttons show only
|
|
368
|
+
* on notifications expo-notifications draws, such as `notifyLocal`.
|
|
369
|
+
*/
|
|
259
370
|
exports.setBadgeCount = setBadgeCount;
|
|
371
|
+
const setCategories = async categories => {
|
|
372
|
+
const mods = resolveModules();
|
|
373
|
+
if (!mods) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
for (const category of categories) {
|
|
377
|
+
await mods.notifications.setNotificationCategoryAsync(category.id, toNotificationActions(category.actions));
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Handles button taps while JavaScript is running; a plain tap still goes to
|
|
383
|
+
* `onOpen`. For taps on a killed app use `definePushActionTask` from
|
|
384
|
+
* `/push/task`, which sets this handler too. Returns a function that removes it.
|
|
385
|
+
*/
|
|
386
|
+
exports.setCategories = setCategories;
|
|
387
|
+
const onAction = handler => {
|
|
388
|
+
actionHandler = handler;
|
|
389
|
+
const mods = resolveModules();
|
|
390
|
+
if (mods) {
|
|
391
|
+
listenForResponses(mods.notifications);
|
|
392
|
+
void drainPending(mods.notifications);
|
|
393
|
+
}
|
|
394
|
+
return () => {
|
|
395
|
+
if (actionHandler === handler) {
|
|
396
|
+
actionHandler = null;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/** Shows a notification from the phone itself, for example when a button's request failed. */
|
|
402
|
+
exports.onAction = onAction;
|
|
403
|
+
const notifyLocal = async ({
|
|
404
|
+
title,
|
|
405
|
+
body,
|
|
406
|
+
data
|
|
407
|
+
}) => {
|
|
408
|
+
const mods = resolveModules();
|
|
409
|
+
if (!mods) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
await mods.notifications.scheduleNotificationAsync({
|
|
413
|
+
content: {
|
|
414
|
+
title,
|
|
415
|
+
body,
|
|
416
|
+
data: data ?? {}
|
|
417
|
+
},
|
|
418
|
+
trigger: null
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
exports.notifyLocal = notifyLocal;
|
|
260
422
|
const subscribe = notify => {
|
|
261
423
|
subscribers.add(notify);
|
|
262
424
|
return () => {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.definePushActionTask = void 0;
|
|
7
|
+
var _reactNative = require("react-native");
|
|
8
|
+
var _index = require("../logger/index.js");
|
|
9
|
+
var _push = require("./push.js");
|
|
10
|
+
const PUSH_ACTION_TASK = 'webority-push-action';
|
|
11
|
+
/**
|
|
12
|
+
* Handles notification button taps in every app state, the killed app
|
|
13
|
+
* included. Call it once at module scope in the app's entry file (index.js),
|
|
14
|
+
* before the root component registers: a tap on a killed app starts the
|
|
15
|
+
* JavaScript bundle without the UI, so nothing later in the app runs.
|
|
16
|
+
*
|
|
17
|
+
* Android runs the handler from a background notification task
|
|
18
|
+
* (expo-task-manager). iOS has no such task for button taps: it launches the
|
|
19
|
+
* app in the background and the response reaches the listener this sets up.
|
|
20
|
+
* The handler's promise is awaited, so a request it makes can finish first.
|
|
21
|
+
*/
|
|
22
|
+
const definePushActionTask = handler => {
|
|
23
|
+
(0, _push.onAction)(handler);
|
|
24
|
+
const mods = (0, _push.resolveModules)();
|
|
25
|
+
if (!mods || _reactNative.Platform.OS !== 'android') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
/** The one place `expo-task-manager` is named. Import `/push/task` only if you use button taps. */
|
|
29
|
+
const taskManager = require('expo-task-manager');
|
|
30
|
+
taskManager.defineTask(PUSH_ACTION_TASK, async ({
|
|
31
|
+
data,
|
|
32
|
+
error
|
|
33
|
+
}) => {
|
|
34
|
+
if (error) {
|
|
35
|
+
_index.Logger.error('[push] the notification task failed', error);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await (0, _push.routeTaskPayload)(data);
|
|
39
|
+
});
|
|
40
|
+
void mods.notifications.registerTaskAsync(PUSH_ACTION_TASK).catch(registerError => _index.Logger.error('[push] registering the notification task failed', registerError));
|
|
41
|
+
};
|
|
42
|
+
exports.definePushActionTask = definePushActionTask;
|
|
43
|
+
//# sourceMappingURL=task.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.encodeBase64 = void 0;
|
|
7
|
+
// Small enough for every engine's argument limit in `String.fromCharCode.apply`.
|
|
8
|
+
const CHUNK = 0x2000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base64 of raw bytes, for a `data:` URI. Builds a Latin-1 string a chunk at a
|
|
12
|
+
* time and hands it to the engine's native `btoa` (Hermes ships it), which is
|
|
13
|
+
* many times faster on the JS thread than encoding byte by byte in JS.
|
|
14
|
+
*/
|
|
15
|
+
const encodeBase64 = buffer => {
|
|
16
|
+
const bytes = new Uint8Array(buffer);
|
|
17
|
+
let binary = '';
|
|
18
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
19
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
|
20
|
+
}
|
|
21
|
+
return btoa(binary);
|
|
22
|
+
};
|
|
23
|
+
exports.encodeBase64 = encodeBase64;
|
|
24
|
+
//# sourceMappingURL=base64.js.map
|
package/lib/module/api/retry.js
CHANGED
|
@@ -11,10 +11,26 @@
|
|
|
11
11
|
* as readily as GETs.
|
|
12
12
|
*/
|
|
13
13
|
export const isRetryableStatus = status => typeof status === 'number' && (status === 429 || status >= 500 && status <= 599);
|
|
14
|
+
const IDEMPOTENT_METHODS = new Set(['get', 'head', 'options', 'put', 'delete']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The same reasoning reaches status codes on a write. A 500 on a POST or PATCH
|
|
18
|
+
* can follow a partial commit, so replaying it can duplicate the record; only
|
|
19
|
+
* 429 and 503 say the server refused the request before doing the work. A
|
|
20
|
+
* request that knows its write is safe to repeat passes its own `shouldRetry`.
|
|
21
|
+
*/
|
|
22
|
+
const isRetryableFailure = error => {
|
|
23
|
+
const status = error.response?.status;
|
|
24
|
+
if (!isRetryableStatus(status)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const method = (error.config?.method ?? 'get').toLowerCase();
|
|
28
|
+
return IDEMPOTENT_METHODS.has(method) || status === 429 || status === 503;
|
|
29
|
+
};
|
|
14
30
|
const DEFAULT_POLICY = {
|
|
15
31
|
retries: 3,
|
|
16
32
|
retryDelayMs: attempt => attempt <= 1 ? 0 : 2 ** attempt * 1000,
|
|
17
|
-
shouldRetry:
|
|
33
|
+
shouldRetry: isRetryableFailure,
|
|
18
34
|
onRetry: () => undefined
|
|
19
35
|
};
|
|
20
36
|
const waitOrAbort = (delay, signal) => new Promise(resolve => {
|
|
@@ -4,7 +4,7 @@ import axios, { AxiosHeaders } from 'axios';
|
|
|
4
4
|
import { ACCESS_TOKEN_KEY, getToken } from "../auth/authStore.js";
|
|
5
5
|
import { getConfigValue } from "../config/index.js";
|
|
6
6
|
import { Logger } from "../logger/index.js";
|
|
7
|
-
import {
|
|
7
|
+
import { getLastKnownNetworkStatus } from "../network/networkStatus.js";
|
|
8
8
|
import "../types/axios.js";
|
|
9
9
|
import { applyConfiguredBaseUrl } from "./baseUrl.js";
|
|
10
10
|
import { logCurl } from "./curl.js";
|
|
@@ -135,7 +135,7 @@ userClient.interceptors.request.use(async config => {
|
|
|
135
135
|
// Reject before any network call when the device is known to be offline.
|
|
136
136
|
// Unknown connectivity (netinfo not installed, or undetermined) is treated
|
|
137
137
|
// as online — never block a request on an unavailable probe.
|
|
138
|
-
const network = await
|
|
138
|
+
const network = await getLastKnownNetworkStatus();
|
|
139
139
|
if (network.isConnected === false) {
|
|
140
140
|
Logger.warn(`Request blocked: device is offline. URL: ${config.url}`);
|
|
141
141
|
return Promise.reject(new RequestOfflineError());
|
|
@@ -83,6 +83,24 @@ const expoSecureStoreBackend = {
|
|
|
83
83
|
}
|
|
84
84
|
};
|
|
85
85
|
const resolveBackend = () => backend ?? expoSecureStoreBackend;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every request reads the access token, and a secure-store read is a native
|
|
89
|
+
* keychain/keystore decrypt. Reads are therefore served from memory, written
|
|
90
|
+
* through by `storeToken` and dropped by `removeToken`. `generation` moves on
|
|
91
|
+
* every write, removal and backend swap, so a read that started before one of
|
|
92
|
+
* those cannot cache the value it saw.
|
|
93
|
+
*/
|
|
94
|
+
const memo = new Map();
|
|
95
|
+
let generation = 0;
|
|
96
|
+
const forget = key => {
|
|
97
|
+
generation += 1;
|
|
98
|
+
if (key === undefined) {
|
|
99
|
+
memo.clear();
|
|
100
|
+
} else {
|
|
101
|
+
memo.delete(key);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
86
104
|
const requireBackend = op => {
|
|
87
105
|
const b = resolveBackend();
|
|
88
106
|
if (!b) {
|
|
@@ -100,6 +118,7 @@ const requireBackend = op => {
|
|
|
100
118
|
*/
|
|
101
119
|
export const setKeychainImplementation = impl => {
|
|
102
120
|
backend = impl;
|
|
121
|
+
forget();
|
|
103
122
|
};
|
|
104
123
|
|
|
105
124
|
/**
|
|
@@ -109,6 +128,7 @@ export const storeToken = async (key, token, expiry) => {
|
|
|
109
128
|
// Never log the token or expiry values: Logger forwards to whatever remote
|
|
110
129
|
// sink the app wired (Crashlytics/Sentry), so a token here leaves the device.
|
|
111
130
|
Logger.info(`Storing token for key: ${key}`);
|
|
131
|
+
forget(key);
|
|
112
132
|
try {
|
|
113
133
|
const data = JSON.stringify({
|
|
114
134
|
token,
|
|
@@ -117,8 +137,14 @@ export const storeToken = async (key, token, expiry) => {
|
|
|
117
137
|
await requireBackend('store a token').setGenericPassword('token', data, {
|
|
118
138
|
service: key
|
|
119
139
|
});
|
|
140
|
+
forget(key);
|
|
141
|
+
memo.set(key, {
|
|
142
|
+
token,
|
|
143
|
+
expiry
|
|
144
|
+
});
|
|
120
145
|
Logger.info(`Token stored successfully for key: ${key}`);
|
|
121
146
|
} catch (error) {
|
|
147
|
+
forget(key);
|
|
122
148
|
// Log AND rethrow. Swallowing this told the caller the token was persisted
|
|
123
149
|
// when it was not, so an app would show a signed-in user holding a token
|
|
124
150
|
// that never reached the keychain — the session then dies at the next
|
|
@@ -156,6 +182,10 @@ const parseTokenData = credentials => {
|
|
|
156
182
|
* Retrieve token and check if expired.
|
|
157
183
|
*/
|
|
158
184
|
export const getToken = async key => {
|
|
185
|
+
if (memo.has(key)) {
|
|
186
|
+
return memo.get(key) ?? null;
|
|
187
|
+
}
|
|
188
|
+
const seen = generation;
|
|
159
189
|
try {
|
|
160
190
|
const credentials = await requireBackend('read a token').getGenericPassword({
|
|
161
191
|
service: key
|
|
@@ -164,24 +194,24 @@ export const getToken = async key => {
|
|
|
164
194
|
// token, and every authenticated request, image and download asks for one.
|
|
165
195
|
// Warning here put three info lines and a warning into the log, and into the
|
|
166
196
|
// remote crash sink, on every one of those calls.
|
|
167
|
-
|
|
168
|
-
|
|
197
|
+
let value = null;
|
|
198
|
+
if (credentials) {
|
|
199
|
+
const parsedData = parseTokenData(credentials);
|
|
200
|
+
if (parsedData) {
|
|
201
|
+
value = {
|
|
202
|
+
token: parsedData.token,
|
|
203
|
+
expiry: parsedData.expiry
|
|
204
|
+
};
|
|
205
|
+
} else {
|
|
206
|
+
// Something IS stored and it is not a token. That is corruption worth
|
|
207
|
+
// hearing about, and it is a different event from having no token.
|
|
208
|
+
Logger.warn(`Stored credentials for key "${key}" are not a valid token; ignoring them.`);
|
|
209
|
+
}
|
|
169
210
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// Something IS stored and it is not a token. That is corruption worth
|
|
173
|
-
// hearing about, and it is a different event from having no token.
|
|
174
|
-
Logger.warn(`Stored credentials for key "${key}" are not a valid token; ignoring them.`);
|
|
175
|
-
return null;
|
|
211
|
+
if (seen === generation) {
|
|
212
|
+
memo.set(key, value);
|
|
176
213
|
}
|
|
177
|
-
|
|
178
|
-
token,
|
|
179
|
-
expiry
|
|
180
|
-
} = parsedData;
|
|
181
|
-
return {
|
|
182
|
-
token,
|
|
183
|
-
expiry
|
|
184
|
-
};
|
|
214
|
+
return value;
|
|
185
215
|
} catch (error) {
|
|
186
216
|
const message = error instanceof Error ? error.message : String(error);
|
|
187
217
|
// Both spellings matter. Android throws "Key permanently invalidated" when
|
|
@@ -204,6 +234,7 @@ export const getToken = async key => {
|
|
|
204
234
|
* Remove token by key.
|
|
205
235
|
*/
|
|
206
236
|
export const removeToken = async key => {
|
|
237
|
+
forget(key);
|
|
207
238
|
try {
|
|
208
239
|
await requireBackend('remove a token').resetGenericPassword({
|
|
209
240
|
service: key
|
|
@@ -219,6 +250,8 @@ export const removeToken = async key => {
|
|
|
219
250
|
return;
|
|
220
251
|
}
|
|
221
252
|
Logger.error(`Failed to remove token for key: ${key}`, error);
|
|
253
|
+
} finally {
|
|
254
|
+
forget(key);
|
|
222
255
|
}
|
|
223
256
|
};
|
|
224
257
|
//# sourceMappingURL=authStore.js.map
|
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
* Provides a flexible way to inject configuration without hard dependencies
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Info lines (each token refresh, each unauthorised URL) reach the device log
|
|
10
|
+
* and any remote sink, so a release build that sets nothing logs warnings and
|
|
11
|
+
* errors only.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_LOG_LEVEL = __DEV__ ? 'info' : 'warn';
|
|
8
14
|
const DEFAULT_CONFIG = {
|
|
9
15
|
baseUrl: null,
|
|
10
16
|
loggingEnabled: true,
|
|
11
|
-
logLevel:
|
|
17
|
+
logLevel: DEFAULT_LOG_LEVEL,
|
|
12
18
|
environment: 'Development',
|
|
13
19
|
country: 'in',
|
|
14
20
|
appId: null,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
import { numberFormat } from "./intlCache.js";
|
|
3
4
|
const hasIntlNumber = () => typeof Intl !== 'undefined' && typeof Intl.NumberFormat === 'function';
|
|
4
5
|
const indianGroupingFallback = (n, fractionDigits) => {
|
|
5
6
|
// Indian numbering: 1,23,45,678.90 (last 3 digits, then groups of 2).
|
|
@@ -37,7 +38,7 @@ export const formatCurrency = (amount, options = {}) => {
|
|
|
37
38
|
const minFrac = minimumFractionDigits ?? (Number.isInteger(amount) ? 0 : 2);
|
|
38
39
|
if (hasIntlNumber()) {
|
|
39
40
|
try {
|
|
40
|
-
const formatted =
|
|
41
|
+
const formatted = numberFormat(locale, {
|
|
41
42
|
style: 'currency',
|
|
42
43
|
currency,
|
|
43
44
|
minimumFractionDigits: minFrac,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
import { dateTimeFormat, relativeTimeFormat } from "./intlCache.js";
|
|
3
4
|
const toDate = value => {
|
|
4
5
|
if (value instanceof Date) {
|
|
5
6
|
return Number.isNaN(value.getTime()) ? null : value;
|
|
@@ -48,7 +49,7 @@ export const formatDate = (value, style = 'medium', locale = 'en-IN') => {
|
|
|
48
49
|
}
|
|
49
50
|
if (hasIntl()) {
|
|
50
51
|
try {
|
|
51
|
-
return
|
|
52
|
+
return dateTimeFormat(locale, {
|
|
52
53
|
dateStyle: style
|
|
53
54
|
}).format(d);
|
|
54
55
|
} catch {
|
|
@@ -68,7 +69,7 @@ export const formatTime = (value, style = 'short', locale = 'en-IN') => {
|
|
|
68
69
|
}
|
|
69
70
|
if (hasIntl()) {
|
|
70
71
|
try {
|
|
71
|
-
return
|
|
72
|
+
return dateTimeFormat(locale, {
|
|
72
73
|
timeStyle: style
|
|
73
74
|
}).format(d);
|
|
74
75
|
} catch {
|
|
@@ -88,7 +89,7 @@ export const formatDateTime = (value, dateStyle = 'medium', timeStyle = 'short',
|
|
|
88
89
|
}
|
|
89
90
|
if (hasIntl()) {
|
|
90
91
|
try {
|
|
91
|
-
return
|
|
92
|
+
return dateTimeFormat(locale, {
|
|
92
93
|
dateStyle,
|
|
93
94
|
timeStyle
|
|
94
95
|
}).format(d);
|
|
@@ -145,9 +146,7 @@ export const formatRelativeTime = (value, base = new Date(), locale = 'en-IN') =
|
|
|
145
146
|
const Rtf = hasIntl() ? Intl.RelativeTimeFormat : undefined;
|
|
146
147
|
if (typeof Rtf === 'function') {
|
|
147
148
|
try {
|
|
148
|
-
return
|
|
149
|
-
numeric: 'auto'
|
|
150
|
-
}).format(amount, unit);
|
|
149
|
+
return relativeTimeFormat(Rtf, locale).format(amount, unit);
|
|
151
150
|
} catch {
|
|
152
151
|
/* fall through */
|
|
153
152
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
import { dateTimeFormat } from "./intlCache.js";
|
|
3
4
|
import { WINDOWS_TO_IANA } from "./windowsToIanaTable.js";
|
|
4
5
|
const toDate = value => {
|
|
5
6
|
if (value instanceof Date) {
|
|
@@ -263,7 +264,7 @@ export const formatTimeInZone = (value, ianaTimeZone) => {
|
|
|
263
264
|
// answer available, not a genuine timezone conversion.
|
|
264
265
|
return formatTime12h(d);
|
|
265
266
|
}
|
|
266
|
-
const parts =
|
|
267
|
+
const parts = dateTimeFormat('en-US', {
|
|
267
268
|
timeZone: ianaTimeZone,
|
|
268
269
|
hour: 'numeric',
|
|
269
270
|
minute: '2-digit',
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Building an Intl formatter costs far more than using one (and on Hermes for
|
|
5
|
+
* Android it goes through ICU over JNI), while list rows and slider frames call
|
|
6
|
+
* the formatters many times with the same arguments. Formatters are therefore
|
|
7
|
+
* built once per locale and options. The limit only guards against unbounded
|
|
8
|
+
* input such as arbitrary time zones; real apps use a handful of keys.
|
|
9
|
+
*/
|
|
10
|
+
const LIMIT = 100;
|
|
11
|
+
const cached = (cache, key, create) => {
|
|
12
|
+
let formatter = cache.get(key);
|
|
13
|
+
if (formatter === undefined) {
|
|
14
|
+
formatter = create();
|
|
15
|
+
if (cache.size >= LIMIT) {
|
|
16
|
+
cache.clear();
|
|
17
|
+
}
|
|
18
|
+
cache.set(key, formatter);
|
|
19
|
+
}
|
|
20
|
+
return formatter;
|
|
21
|
+
};
|
|
22
|
+
const numberFormats = new Map();
|
|
23
|
+
const dateTimeFormats = new Map();
|
|
24
|
+
const relativeTimeFormats = new Map();
|
|
25
|
+
export const numberFormat = (locale, options = {}) => cached(numberFormats, `${locale}|${JSON.stringify(options)}`, () => new Intl.NumberFormat(locale, options));
|
|
26
|
+
export const dateTimeFormat = (locale, options) => cached(dateTimeFormats, `${locale}|${JSON.stringify(options)}`, () => new Intl.DateTimeFormat(locale, options));
|
|
27
|
+
export const relativeTimeFormat = (RelativeTimeFormat, locale) => cached(relativeTimeFormats, locale, () => new RelativeTimeFormat(locale, {
|
|
28
|
+
numeric: 'auto'
|
|
29
|
+
}));
|
|
30
|
+
//# sourceMappingURL=intlCache.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
import { numberFormat } from "./intlCache.js";
|
|
3
4
|
const hasIntlNumber = () => typeof Intl !== 'undefined' && typeof Intl.NumberFormat === 'function';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -16,7 +17,7 @@ export const formatNumber = (value, fractionDigits, locale = 'en-IN') => {
|
|
|
16
17
|
opts.minimumFractionDigits = fractionDigits;
|
|
17
18
|
opts.maximumFractionDigits = fractionDigits;
|
|
18
19
|
}
|
|
19
|
-
return
|
|
20
|
+
return numberFormat(locale, opts).format(value);
|
|
20
21
|
} catch {
|
|
21
22
|
/* fall through */
|
|
22
23
|
}
|
|
@@ -35,7 +36,7 @@ export const formatPercent = (value, fractionDigits = 0, asRatio = true, locale
|
|
|
35
36
|
const ratio = asRatio ? value : value / 100;
|
|
36
37
|
if (hasIntlNumber()) {
|
|
37
38
|
try {
|
|
38
|
-
return
|
|
39
|
+
return numberFormat(locale, {
|
|
39
40
|
style: 'percent',
|
|
40
41
|
minimumFractionDigits: fractionDigits,
|
|
41
42
|
maximumFractionDigits: fractionDigits
|
|
@@ -57,7 +58,7 @@ export const formatCompactNumber = (value, locale = 'en-IN') => {
|
|
|
57
58
|
}
|
|
58
59
|
if (hasIntlNumber()) {
|
|
59
60
|
try {
|
|
60
|
-
return
|
|
61
|
+
return numberFormat(locale, {
|
|
61
62
|
notation: 'compact',
|
|
62
63
|
maximumFractionDigits: 1
|
|
63
64
|
}).format(value);
|
package/lib/module/index.js
CHANGED
|
@@ -48,6 +48,8 @@ export { addNetworkStatusListener, getNetworkStatus, setNetworkStatusImplementat
|
|
|
48
48
|
// Storage: the API and the seam. No native package is required; wire one with
|
|
49
49
|
// setStorageImplementation to get persistence.
|
|
50
50
|
export { isStorageAvailable, Storage, setStorageImplementation } from "./storage/index.js";
|
|
51
|
+
// Bytes
|
|
52
|
+
export { encodeBase64 } from "./utils/base64.js";
|
|
51
53
|
// Validators
|
|
52
54
|
export { isAadhaar, isAlphanumeric, isBetween, isEmail, isGstin, isHexColor, isIfsc, isIndianMobile, isIndianPincode, isInteger, isNonEmpty, isNumeric, isPan, isPhone, isStrongPassword, isUrl, maxLength, minLength } from "./validators/index.js";
|
|
53
55
|
// Version check (the ForceUpdateDialog UI lives in @webority/mobile-ui)
|
package/lib/module/initSDK.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { setKeychainImplementation } from "./auth/authStore.js";
|
|
9
|
-
import { setConfig } from "./config/index.js";
|
|
9
|
+
import { DEFAULT_LOG_LEVEL, setConfig } from "./config/index.js";
|
|
10
10
|
import { Logger } from "./logger/index.js";
|
|
11
11
|
import { setNetworkStatusImplementation } from "./network/networkStatus.js";
|
|
12
12
|
import { setStorageImplementation } from "./storage/index.js";
|
|
@@ -35,7 +35,7 @@ export const initWebority = config => {
|
|
|
35
35
|
setConfig({
|
|
36
36
|
baseUrl: config.baseUrl,
|
|
37
37
|
loggingEnabled: config.loggingEnabled ?? true,
|
|
38
|
-
logLevel: config.logLevel ||
|
|
38
|
+
logLevel: config.logLevel || DEFAULT_LOG_LEVEL,
|
|
39
39
|
environment: config.environment || 'Development',
|
|
40
40
|
country: (config.country || 'in').toLowerCase(),
|
|
41
41
|
appId: config.appId ?? null,
|