@webority/mobile-core 0.0.27 → 0.0.29

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 CHANGED
@@ -30,6 +30,7 @@ import { openDatabase } from '@webority/mobile-core/sqlite';
30
30
  import { downloadFile } from '@webority/mobile-core/download';
31
31
  import { shareFile } from '@webority/mobile-core/share';
32
32
  import { initPush, enablePush, usePush } from '@webority/mobile-core/push';
33
+ import { definePushActionTask } from '@webority/mobile-core/push/task';
33
34
  import { compressImage } from '@webority/mobile-core/compressor';
34
35
  ```
35
36
 
@@ -43,7 +44,74 @@ the native token (`Fcm` on Android, `Apns` on iOS) and the bundle id to `registe
43
44
  re-registers when the token changes; `disablePush` calls `unregister`. `onOpen` receives the
44
45
  tapped notification's data as strings, including the tap that launched the app. `usePush` gives a
45
46
  settings switch its `status` and `enabled`. The library does not remember the switch: an app that
46
- keeps push on calls `enablePush` at each start.
47
+ keeps push on calls `enablePush` at each start. `setBadgeCount(count)` sets the app icon badge.
48
+
49
+ ### Notification buttons
50
+
51
+ A push names a category; the category's buttons are registered on the phone. Register them at
52
+ every start, with the same ids on both platforms:
53
+
54
+ ```ts
55
+ import { setCategories, notifyLocal } from '@webority/mobile-core/push';
56
+
57
+ await setCategories([
58
+ {
59
+ id: 'approval',
60
+ actions: [
61
+ { id: 'approve', title: 'Approve', opensApp: false },
62
+ { id: 'reject', title: 'Reject', destructive: true }
63
+ ]
64
+ },
65
+ {
66
+ id: 'reply',
67
+ actions: [
68
+ {
69
+ id: 'reply',
70
+ title: 'Reply',
71
+ opensApp: false,
72
+ textInput: { submitTitle: 'Send', placeholder: 'Write a reply' }
73
+ }
74
+ ]
75
+ }
76
+ ]);
77
+ ```
78
+
79
+ `opensApp` defaults to true; `destructive` and `authenticationRequired` (unlock first) are iOS
80
+ only. A button tap arrives as `{ actionId, userText, data, notificationId }`: `userText` is the
81
+ reply text, `data` the push data as strings, `notificationId` the notification's identifier on
82
+ the phone. A plain tap still goes to `onOpen`; a button tap with no handler opens like a plain tap.
83
+
84
+ To receive button taps in every app state, the killed app included, call `definePushActionTask`
85
+ once at module scope in the entry file, before the root component registers. It needs
86
+ `expo-task-manager`, an optional peer (`npx expo install expo-task-manager`), so it lives on its
87
+ own path:
88
+
89
+ ```js
90
+ // index.js
91
+ import { definePushActionTask } from '@webority/mobile-core/push/task';
92
+ import { notifyLocal } from '@webority/mobile-core/push';
93
+
94
+ definePushActionTask(async ({ actionId, userText, data }) => {
95
+ try {
96
+ await settle(actionId, userText, data);
97
+ } catch {
98
+ await notifyLocal({ title: 'Could not approve', body: 'Open the app to try again.' });
99
+ }
100
+ });
101
+ ```
102
+
103
+ The handler's promise is awaited, so its request can finish before the background work ends.
104
+ `onAction(handler)` from `/push` sets the same handler for taps while JavaScript is running and
105
+ returns a function that removes it. A response reported twice within ten seconds (the launch read,
106
+ the listener and the Android task can each report it) runs once.
107
+
108
+ - **Android:** the background notification task runs the handler for a button tap on a
109
+ backgrounded or killed app. Buttons appear only on notifications expo-notifications draws (for
110
+ example `notifyLocal`). An app that draws its own notifications natively from data-only pushes
111
+ must build each button's intent with expo's `NotificationsService.createNotificationResponseIntent`,
112
+ so the tap reaches this handler.
113
+ - **iOS:** a button with `opensApp: false` launches the app in the background; the response
114
+ reaches the listener `definePushActionTask` sets up. The task is not registered on iOS.
47
115
 
48
116
  Storage and network status stay on the main barrel but load no package on their own. Wire them at
49
117
  startup with `setStorageImplementation` and `setNetworkStatusImplementation` if you want them.
@@ -21,12 +21,30 @@ Object.defineProperty(exports, "initPush", {
21
21
  return _push.initPush;
22
22
  }
23
23
  });
24
+ Object.defineProperty(exports, "notifyLocal", {
25
+ enumerable: true,
26
+ get: function () {
27
+ return _push.notifyLocal;
28
+ }
29
+ });
30
+ Object.defineProperty(exports, "onAction", {
31
+ enumerable: true,
32
+ get: function () {
33
+ return _push.onAction;
34
+ }
35
+ });
24
36
  Object.defineProperty(exports, "setBadgeCount", {
25
37
  enumerable: true,
26
38
  get: function () {
27
39
  return _push.setBadgeCount;
28
40
  }
29
41
  });
42
+ Object.defineProperty(exports, "setCategories", {
43
+ enumerable: true,
44
+ get: function () {
45
+ return _push.setCategories;
46
+ }
47
+ });
30
48
  Object.defineProperty(exports, "setPushImplementation", {
31
49
  enumerable: true,
32
50
  get: function () {
@@ -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 lastOpenedId = null;
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,137 @@ const toStringData = data => {
96
114
  }
97
115
  return out;
98
116
  };
99
- const handleResponse = response => {
100
- if (!response || !config) {
117
+ const contentData = 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
+
132
+ /**
133
+ * expo-notifications fills iOS `content.data` only from a `body` object, but our
134
+ * servers send raw APNs with the app's keys at the top level beside `aps`.
135
+ */
136
+ const triggerData = response => {
137
+ const trigger = response.notification.request.trigger;
138
+ const extra = {
139
+ ...(trigger?.remoteMessage?.data ?? {})
140
+ };
141
+ for (const [key, value] of Object.entries(trigger?.payload ?? {})) {
142
+ // A `body` object is what expo already turned into content.data.
143
+ if (key === 'aps' || key === 'body' && typeof value === 'object') {
144
+ continue;
145
+ }
146
+ extra[key] = value;
147
+ }
148
+ return toStringData(extra);
149
+ };
150
+
151
+ /** The push's own data merged over the raw remote payload's keys, so content.data wins a conflict. */
152
+ const responseData = response => ({
153
+ ...triggerData(response),
154
+ ...contentData(response)
155
+ });
156
+ const toPushAction = response => ({
157
+ actionId: response.actionIdentifier,
158
+ ...(response.userText ? {
159
+ userText: response.userText
160
+ } : {}),
161
+ data: responseData(response),
162
+ notificationId: response.notification.request.identifier
163
+ });
164
+ exports.toPushAction = toPushAction;
165
+ const toNotificationActions = actions => actions.map(action => ({
166
+ identifier: action.id,
167
+ buttonTitle: action.title,
168
+ ...(action.textInput ? {
169
+ textInput: {
170
+ submitButtonTitle: action.textInput.submitTitle,
171
+ placeholder: action.textInput.placeholder
172
+ }
173
+ } : {}),
174
+ options: {
175
+ opensAppToForeground: action.opensApp ?? true,
176
+ isDestructive: action.destructive ?? false,
177
+ isAuthenticationRequired: action.authenticationRequired ?? false
178
+ }
179
+ }));
180
+
181
+ /**
182
+ * Sends a button tap to the action handler and a plain tap to `onOpen`. A
183
+ * button tap with no action handler opens like a plain tap. Resolves once the
184
+ * handler has finished, so a background task stays alive for its request.
185
+ */
186
+ exports.toNotificationActions = toNotificationActions;
187
+ const routeResponse = async (m, response) => {
188
+ const key = `${response.notification.request.identifier}:${response.actionIdentifier}`;
189
+ const last = routedAt.get(key);
190
+ if (last !== undefined && Date.now() - last < REPEAT_WINDOW_MS) {
101
191
  return;
102
192
  }
103
- // The cold-start read and the listener can both report the launching tap.
104
- const id = `${response.notification.request.identifier}:${response.actionIdentifier}`;
105
- if (id === lastOpenedId) {
193
+ const isAction = response.actionIdentifier !== m.DEFAULT_ACTION_IDENTIFIER;
194
+ if (isAction && actionHandler) {
195
+ routedAt.set(key, Date.now());
196
+ try {
197
+ await actionHandler(toPushAction(response));
198
+ } catch (error) {
199
+ _index.Logger.error('[push] onAction threw', error);
200
+ }
201
+ return;
202
+ }
203
+ if (!config) {
204
+ pendingResponses.push(response);
106
205
  return;
107
206
  }
108
- lastOpenedId = id;
207
+ routedAt.set(key, Date.now());
109
208
  try {
110
- config.onOpen(toStringData(response.notification.request.content.data));
209
+ config.onOpen(responseData(response));
111
210
  } catch (error) {
112
211
  _index.Logger.error('[push] onOpen threw', error);
113
212
  }
114
213
  };
214
+ const drainPending = async m => {
215
+ const queued = pendingResponses.splice(0);
216
+ for (const response of queued) {
217
+ await routeResponse(m, response);
218
+ }
219
+ };
220
+
221
+ /** Follows taps and button taps, including the one that launched the app. Wired once per process. */
222
+ const listenForResponses = m => {
223
+ if (listening) {
224
+ return;
225
+ }
226
+ listening = true;
227
+ m.addNotificationResponseReceivedListener(response => {
228
+ void routeResponse(m, response);
229
+ });
230
+ void m.getLastNotificationResponseAsync().then(async response => {
231
+ if (response) {
232
+ await routeResponse(m, response);
233
+ }
234
+ return m.clearLastNotificationResponseAsync?.();
235
+ }).catch(error => _index.Logger.error('[push] reading the launch notification failed', error));
236
+ };
237
+
238
+ /** The payload of the background notification task: routes button taps, ignores received pushes. */
239
+ const routeTaskPayload = async payload => {
240
+ const mods = resolveModules();
241
+ const response = payload;
242
+ if (!mods || typeof response?.actionIdentifier !== 'string' || typeof response.notification?.request?.identifier !== 'string') {
243
+ return;
244
+ }
245
+ await routeResponse(mods.notifications, response);
246
+ };
247
+ exports.routeTaskPayload = routeTaskPayload;
115
248
  const createChannels = async (m, channels) => {
116
249
  for (const channel of channels) {
117
250
  try {
@@ -174,11 +307,8 @@ const initPush = next => {
174
307
  if (_reactNative.Platform.OS === 'android' && next.androidChannels?.length) {
175
308
  channelsReady = createChannels(m, next.androidChannels);
176
309
  }
177
- m.addNotificationResponseReceivedListener(handleResponse);
178
- void m.getLastNotificationResponseAsync().then(response => {
179
- handleResponse(response);
180
- return m.clearLastNotificationResponseAsync?.();
181
- }).catch(error => _index.Logger.error('[push] reading the launch notification failed', error));
310
+ listenForResponses(m);
311
+ void drainPending(m);
182
312
  void m.getPermissionsAsync().then(response => setState({
183
313
  status: toPermissionStatus(response)
184
314
  })).catch(error => _index.Logger.error('[push] permission check failed', error));
@@ -256,7 +386,64 @@ const setBadgeCount = async count => {
256
386
  }
257
387
  await mods.notifications.setBadgeCountAsync(count);
258
388
  };
389
+
390
+ /**
391
+ * Registers the button sets a push can name by category id. Call it at every
392
+ * start, with the same ids on both platforms. On Android the buttons show only
393
+ * on notifications expo-notifications draws, such as `notifyLocal`.
394
+ */
259
395
  exports.setBadgeCount = setBadgeCount;
396
+ const setCategories = async categories => {
397
+ const mods = resolveModules();
398
+ if (!mods) {
399
+ return;
400
+ }
401
+ for (const category of categories) {
402
+ await mods.notifications.setNotificationCategoryAsync(category.id, toNotificationActions(category.actions));
403
+ }
404
+ };
405
+
406
+ /**
407
+ * Handles button taps while JavaScript is running; a plain tap still goes to
408
+ * `onOpen`. For taps on a killed app use `definePushActionTask` from
409
+ * `/push/task`, which sets this handler too. Returns a function that removes it.
410
+ */
411
+ exports.setCategories = setCategories;
412
+ const onAction = handler => {
413
+ actionHandler = handler;
414
+ const mods = resolveModules();
415
+ if (mods) {
416
+ listenForResponses(mods.notifications);
417
+ void drainPending(mods.notifications);
418
+ }
419
+ return () => {
420
+ if (actionHandler === handler) {
421
+ actionHandler = null;
422
+ }
423
+ };
424
+ };
425
+
426
+ /** Shows a notification from the phone itself, for example when a button's request failed. */
427
+ exports.onAction = onAction;
428
+ const notifyLocal = async ({
429
+ title,
430
+ body,
431
+ data
432
+ }) => {
433
+ const mods = resolveModules();
434
+ if (!mods) {
435
+ return;
436
+ }
437
+ await mods.notifications.scheduleNotificationAsync({
438
+ content: {
439
+ title,
440
+ body,
441
+ data: data ?? {}
442
+ },
443
+ trigger: null
444
+ });
445
+ };
446
+ exports.notifyLocal = notifyLocal;
260
447
  const subscribe = notify => {
261
448
  subscribers.add(notify);
262
449
  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
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
 
3
- export { disablePush, enablePush, initPush, setBadgeCount, setPushImplementation, usePush } from "./push.js";
3
+ export { disablePush, enablePush, initPush, notifyLocal, onAction, setBadgeCount, setCategories, setPushImplementation, usePush } from "./push.js";
4
4
  //# sourceMappingURL=index.js.map
@@ -4,6 +4,12 @@ import { useSyncExternalStore } from 'react';
4
4
  import { Platform } from 'react-native';
5
5
  import { Logger } from "../logger/index.js";
6
6
 
7
+ /** A button on a notification category. */
8
+
9
+ /** A set of buttons a push names through its category id. */
10
+
11
+ /** A button tap on a notification. */
12
+
7
13
  /** The slice of expo-notifications this module calls, so the optional peer stays untyped at build. */
8
14
 
9
15
  let override = null;
@@ -23,9 +29,9 @@ export const setPushImplementation = impl => {
23
29
  /**
24
30
  * The only place expo-notifications and expo-application are named. Expo Go is
25
31
  * checked first because expo-notifications refuses remote push there and its
26
- * import throws on Android.
32
+ * import throws on Android. Exported for `./task`; not on the `/push` entry.
27
33
  */
28
- const resolveModules = () => {
34
+ export const resolveModules = () => {
29
35
  if (override) {
30
36
  return override;
31
37
  }
@@ -67,7 +73,18 @@ const setState = next => {
67
73
  let config = null;
68
74
  let channelsReady = Promise.resolve();
69
75
  let tokenSubscription = null;
70
- let lastOpenedId = null;
76
+ let actionHandler = null;
77
+ let listening = false;
78
+ /** Responses that arrived before anything could handle them, e.g. the launching tap before initPush. */
79
+ const pendingResponses = [];
80
+ /** Response key to when it was routed. */
81
+ const routedAt = new Map();
82
+
83
+ /**
84
+ * The cold-start read, the live listener and, on Android, the background task
85
+ * can each report the same response a moment apart.
86
+ */
87
+ const REPEAT_WINDOW_MS = 10_000;
71
88
  const requireConfig = () => {
72
89
  if (!config) {
73
90
  throw new Error('[@webority/mobile-core] call initPush before enablePush or disablePush.');
@@ -92,22 +109,134 @@ const toStringData = data => {
92
109
  }
93
110
  return out;
94
111
  };
95
- const handleResponse = response => {
96
- if (!response || !config) {
112
+ const contentData = response => {
113
+ const content = response.notification.request.content;
114
+ if (content.data) {
115
+ return toStringData(content.data);
116
+ }
117
+ if (typeof content.dataString === 'string') {
118
+ try {
119
+ return toStringData(JSON.parse(content.dataString));
120
+ } catch (error) {
121
+ Logger.error('[push] notification data is not JSON', error);
122
+ }
123
+ }
124
+ return {};
125
+ };
126
+
127
+ /**
128
+ * expo-notifications fills iOS `content.data` only from a `body` object, but our
129
+ * servers send raw APNs with the app's keys at the top level beside `aps`.
130
+ */
131
+ const triggerData = response => {
132
+ const trigger = response.notification.request.trigger;
133
+ const extra = {
134
+ ...(trigger?.remoteMessage?.data ?? {})
135
+ };
136
+ for (const [key, value] of Object.entries(trigger?.payload ?? {})) {
137
+ // A `body` object is what expo already turned into content.data.
138
+ if (key === 'aps' || key === 'body' && typeof value === 'object') {
139
+ continue;
140
+ }
141
+ extra[key] = value;
142
+ }
143
+ return toStringData(extra);
144
+ };
145
+
146
+ /** The push's own data merged over the raw remote payload's keys, so content.data wins a conflict. */
147
+ const responseData = response => ({
148
+ ...triggerData(response),
149
+ ...contentData(response)
150
+ });
151
+ export const toPushAction = response => ({
152
+ actionId: response.actionIdentifier,
153
+ ...(response.userText ? {
154
+ userText: response.userText
155
+ } : {}),
156
+ data: responseData(response),
157
+ notificationId: response.notification.request.identifier
158
+ });
159
+ export const toNotificationActions = actions => actions.map(action => ({
160
+ identifier: action.id,
161
+ buttonTitle: action.title,
162
+ ...(action.textInput ? {
163
+ textInput: {
164
+ submitButtonTitle: action.textInput.submitTitle,
165
+ placeholder: action.textInput.placeholder
166
+ }
167
+ } : {}),
168
+ options: {
169
+ opensAppToForeground: action.opensApp ?? true,
170
+ isDestructive: action.destructive ?? false,
171
+ isAuthenticationRequired: action.authenticationRequired ?? false
172
+ }
173
+ }));
174
+
175
+ /**
176
+ * Sends a button tap to the action handler and a plain tap to `onOpen`. A
177
+ * button tap with no action handler opens like a plain tap. Resolves once the
178
+ * handler has finished, so a background task stays alive for its request.
179
+ */
180
+ const routeResponse = async (m, response) => {
181
+ const key = `${response.notification.request.identifier}:${response.actionIdentifier}`;
182
+ const last = routedAt.get(key);
183
+ if (last !== undefined && Date.now() - last < REPEAT_WINDOW_MS) {
184
+ return;
185
+ }
186
+ const isAction = response.actionIdentifier !== m.DEFAULT_ACTION_IDENTIFIER;
187
+ if (isAction && actionHandler) {
188
+ routedAt.set(key, Date.now());
189
+ try {
190
+ await actionHandler(toPushAction(response));
191
+ } catch (error) {
192
+ Logger.error('[push] onAction threw', error);
193
+ }
97
194
  return;
98
195
  }
99
- // The cold-start read and the listener can both report the launching tap.
100
- const id = `${response.notification.request.identifier}:${response.actionIdentifier}`;
101
- if (id === lastOpenedId) {
196
+ if (!config) {
197
+ pendingResponses.push(response);
102
198
  return;
103
199
  }
104
- lastOpenedId = id;
200
+ routedAt.set(key, Date.now());
105
201
  try {
106
- config.onOpen(toStringData(response.notification.request.content.data));
202
+ config.onOpen(responseData(response));
107
203
  } catch (error) {
108
204
  Logger.error('[push] onOpen threw', error);
109
205
  }
110
206
  };
207
+ const drainPending = async m => {
208
+ const queued = pendingResponses.splice(0);
209
+ for (const response of queued) {
210
+ await routeResponse(m, response);
211
+ }
212
+ };
213
+
214
+ /** Follows taps and button taps, including the one that launched the app. Wired once per process. */
215
+ const listenForResponses = m => {
216
+ if (listening) {
217
+ return;
218
+ }
219
+ listening = true;
220
+ m.addNotificationResponseReceivedListener(response => {
221
+ void routeResponse(m, response);
222
+ });
223
+ void m.getLastNotificationResponseAsync().then(async response => {
224
+ if (response) {
225
+ await routeResponse(m, response);
226
+ }
227
+ return m.clearLastNotificationResponseAsync?.();
228
+ }).catch(error => Logger.error('[push] reading the launch notification failed', error));
229
+ };
230
+
231
+ /** The payload of the background notification task: routes button taps, ignores received pushes. */
232
+ export const routeTaskPayload = async payload => {
233
+ const mods = resolveModules();
234
+ const response = payload;
235
+ if (!mods || typeof response?.actionIdentifier !== 'string' || typeof response.notification?.request?.identifier !== 'string') {
236
+ return;
237
+ }
238
+ await routeResponse(mods.notifications, response);
239
+ };
111
240
  const createChannels = async (m, channels) => {
112
241
  for (const channel of channels) {
113
242
  try {
@@ -170,11 +299,8 @@ export const initPush = next => {
170
299
  if (Platform.OS === 'android' && next.androidChannels?.length) {
171
300
  channelsReady = createChannels(m, next.androidChannels);
172
301
  }
173
- m.addNotificationResponseReceivedListener(handleResponse);
174
- void m.getLastNotificationResponseAsync().then(response => {
175
- handleResponse(response);
176
- return m.clearLastNotificationResponseAsync?.();
177
- }).catch(error => Logger.error('[push] reading the launch notification failed', error));
302
+ listenForResponses(m);
303
+ void drainPending(m);
178
304
  void m.getPermissionsAsync().then(response => setState({
179
305
  status: toPermissionStatus(response)
180
306
  })).catch(error => Logger.error('[push] permission check failed', error));
@@ -249,6 +375,60 @@ export const setBadgeCount = async count => {
249
375
  }
250
376
  await mods.notifications.setBadgeCountAsync(count);
251
377
  };
378
+
379
+ /**
380
+ * Registers the button sets a push can name by category id. Call it at every
381
+ * start, with the same ids on both platforms. On Android the buttons show only
382
+ * on notifications expo-notifications draws, such as `notifyLocal`.
383
+ */
384
+ export const setCategories = async categories => {
385
+ const mods = resolveModules();
386
+ if (!mods) {
387
+ return;
388
+ }
389
+ for (const category of categories) {
390
+ await mods.notifications.setNotificationCategoryAsync(category.id, toNotificationActions(category.actions));
391
+ }
392
+ };
393
+
394
+ /**
395
+ * Handles button taps while JavaScript is running; a plain tap still goes to
396
+ * `onOpen`. For taps on a killed app use `definePushActionTask` from
397
+ * `/push/task`, which sets this handler too. Returns a function that removes it.
398
+ */
399
+ export const onAction = handler => {
400
+ actionHandler = handler;
401
+ const mods = resolveModules();
402
+ if (mods) {
403
+ listenForResponses(mods.notifications);
404
+ void drainPending(mods.notifications);
405
+ }
406
+ return () => {
407
+ if (actionHandler === handler) {
408
+ actionHandler = null;
409
+ }
410
+ };
411
+ };
412
+
413
+ /** Shows a notification from the phone itself, for example when a button's request failed. */
414
+ export const notifyLocal = async ({
415
+ title,
416
+ body,
417
+ data
418
+ }) => {
419
+ const mods = resolveModules();
420
+ if (!mods) {
421
+ return;
422
+ }
423
+ await mods.notifications.scheduleNotificationAsync({
424
+ content: {
425
+ title,
426
+ body,
427
+ data: data ?? {}
428
+ },
429
+ trigger: null
430
+ });
431
+ };
252
432
  const subscribe = notify => {
253
433
  subscribers.add(notify);
254
434
  return () => {
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+
3
+ import { Platform } from 'react-native';
4
+ import { Logger } from "../logger/index.js";
5
+ import { onAction, resolveModules, routeTaskPayload } from "./push.js";
6
+ const PUSH_ACTION_TASK = 'webority-push-action';
7
+ /**
8
+ * Handles notification button taps in every app state, the killed app
9
+ * included. Call it once at module scope in the app's entry file (index.js),
10
+ * before the root component registers: a tap on a killed app starts the
11
+ * JavaScript bundle without the UI, so nothing later in the app runs.
12
+ *
13
+ * Android runs the handler from a background notification task
14
+ * (expo-task-manager). iOS has no such task for button taps: it launches the
15
+ * app in the background and the response reaches the listener this sets up.
16
+ * The handler's promise is awaited, so a request it makes can finish first.
17
+ */
18
+ export const definePushActionTask = handler => {
19
+ onAction(handler);
20
+ const mods = resolveModules();
21
+ if (!mods || Platform.OS !== 'android') {
22
+ return;
23
+ }
24
+ /** The one place `expo-task-manager` is named. Import `/push/task` only if you use button taps. */
25
+ const taskManager = require('expo-task-manager');
26
+ taskManager.defineTask(PUSH_ACTION_TASK, async ({
27
+ data,
28
+ error
29
+ }) => {
30
+ if (error) {
31
+ Logger.error('[push] the notification task failed', error);
32
+ return;
33
+ }
34
+ await routeTaskPayload(data);
35
+ });
36
+ void mods.notifications.registerTaskAsync(PUSH_ACTION_TASK).catch(registerError => Logger.error('[push] registering the notification task failed', registerError));
37
+ };
38
+ //# sourceMappingURL=task.js.map
@@ -1,3 +1,3 @@
1
- export type { PushAndroidChannel, PushConfig, PushDevice, PushModules, PushNotificationsLike, PushPlatform, PushState, PushStatus } from './push';
2
- export { disablePush, enablePush, initPush, setBadgeCount, setPushImplementation, usePush } from './push';
1
+ export type { PushAction, PushActionHandler, PushAndroidChannel, PushCategory, PushCategoryAction, PushConfig, PushDevice, PushLocalNotification, PushModules, PushNotificationsLike, PushPlatform, PushState, PushStatus } from './push';
2
+ export { disablePush, enablePush, initPush, notifyLocal, onAction, setBadgeCount, setCategories, setPushImplementation, usePush } from './push';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -26,6 +26,42 @@ export interface PushConfig {
26
26
  /** Whether a notification shown while the app is open plays its sound. Defaults to true. */
27
27
  playSoundInForeground?: () => boolean;
28
28
  }
29
+ /** A button on a notification category. */
30
+ export interface PushCategoryAction {
31
+ id: string;
32
+ title: string;
33
+ /** Whether the tap brings the app to the foreground. Defaults to true. */
34
+ opensApp?: boolean;
35
+ /** iOS only: the title shows in red. */
36
+ destructive?: boolean;
37
+ /** Turns the button into a reply box; the text arrives as `userText`. */
38
+ textInput?: {
39
+ submitTitle: string;
40
+ placeholder: string;
41
+ };
42
+ /** iOS only: the phone must be unlocked before the action runs. */
43
+ authenticationRequired?: boolean;
44
+ }
45
+ /** A set of buttons a push names through its category id. */
46
+ export interface PushCategory {
47
+ id: string;
48
+ actions: PushCategoryAction[];
49
+ }
50
+ /** A button tap on a notification. */
51
+ export interface PushAction {
52
+ actionId: string;
53
+ /** What the person typed, for a `textInput` action. */
54
+ userText?: string;
55
+ data: Record<string, string>;
56
+ /** The notification's identifier on the phone, not a server id in `data`. */
57
+ notificationId: string;
58
+ }
59
+ export type PushActionHandler = (action: PushAction) => Promise<void> | void;
60
+ export interface PushLocalNotification {
61
+ title: string;
62
+ body: string;
63
+ data?: Record<string, string>;
64
+ }
29
65
  export type PushStatus = 'unknown' | 'granted' | 'denied' | 'unavailable';
30
66
  export interface PushState {
31
67
  status: PushStatus;
@@ -41,15 +77,39 @@ interface SubscriptionLike {
41
77
  }
42
78
  interface NotificationResponseLike {
43
79
  actionIdentifier: string;
80
+ userText?: string;
44
81
  notification: {
45
82
  request: {
46
83
  identifier: string;
84
+ /** A background task receives the raw content, where data may still be a JSON `dataString`. */
47
85
  content: {
48
86
  data?: Record<string, unknown> | null;
87
+ dataString?: string;
49
88
  };
89
+ /** A remote push carries its raw payload: APNs `userInfo` on iOS, the FCM message on Android. */
90
+ trigger?: {
91
+ type?: string;
92
+ payload?: Record<string, unknown> | null;
93
+ remoteMessage?: {
94
+ data?: Record<string, unknown> | null;
95
+ } | null;
96
+ } | null;
50
97
  };
51
98
  };
52
99
  }
100
+ interface NotificationActionLike {
101
+ identifier: string;
102
+ buttonTitle: string;
103
+ textInput?: {
104
+ submitButtonTitle: string;
105
+ placeholder: string;
106
+ };
107
+ options: {
108
+ opensAppToForeground: boolean;
109
+ isDestructive: boolean;
110
+ isAuthenticationRequired: boolean;
111
+ };
112
+ }
53
113
  interface DevicePushTokenLike {
54
114
  type: string;
55
115
  data: unknown;
@@ -76,10 +136,22 @@ export interface PushNotificationsLike {
76
136
  importance: number;
77
137
  }) => Promise<unknown>;
78
138
  setBadgeCountAsync: (count: number) => Promise<boolean>;
139
+ setNotificationCategoryAsync: (identifier: string, actions: NotificationActionLike[]) => Promise<unknown>;
140
+ scheduleNotificationAsync: (request: {
141
+ content: {
142
+ title: string;
143
+ body: string;
144
+ data: Record<string, string>;
145
+ };
146
+ trigger: null;
147
+ }) => Promise<string>;
148
+ registerTaskAsync: (taskName: string) => Promise<unknown>;
79
149
  AndroidImportance: {
80
150
  DEFAULT: number;
81
151
  HIGH: number;
82
152
  };
153
+ /** The `actionIdentifier` of a plain tap on the notification body. */
154
+ DEFAULT_ACTION_IDENTIFIER: string;
83
155
  }
84
156
  export interface PushModules {
85
157
  notifications: PushNotificationsLike;
@@ -92,6 +164,16 @@ export interface PushModules {
92
164
  * included. Pass `null` to restore expo-notifications.
93
165
  */
94
166
  export declare const setPushImplementation: (impl: PushModules | null) => void;
167
+ /**
168
+ * The only place expo-notifications and expo-application are named. Expo Go is
169
+ * checked first because expo-notifications refuses remote push there and its
170
+ * import throws on Android. Exported for `./task`; not on the `/push` entry.
171
+ */
172
+ export declare const resolveModules: () => PushModules | null;
173
+ export declare const toPushAction: (response: NotificationResponseLike) => PushAction;
174
+ export declare const toNotificationActions: (actions: PushCategoryAction[]) => NotificationActionLike[];
175
+ /** The payload of the background notification task: routes button taps, ignores received pushes. */
176
+ export declare const routeTaskPayload: (payload: unknown) => Promise<void>;
95
177
  /**
96
178
  * Wires push at startup: the foreground handler, Android channels, and tap
97
179
  * handling, including the tap that launched the app. Calling it again only
@@ -110,6 +192,20 @@ export declare const enablePush: () => Promise<"granted" | "denied" | "unavailab
110
192
  export declare const disablePush: () => Promise<void>;
111
193
  /** Sets the app icon badge; a no-op where push is unavailable or the launcher has no badge. */
112
194
  export declare const setBadgeCount: (count: number) => Promise<void>;
195
+ /**
196
+ * Registers the button sets a push can name by category id. Call it at every
197
+ * start, with the same ids on both platforms. On Android the buttons show only
198
+ * on notifications expo-notifications draws, such as `notifyLocal`.
199
+ */
200
+ export declare const setCategories: (categories: PushCategory[]) => Promise<void>;
201
+ /**
202
+ * Handles button taps while JavaScript is running; a plain tap still goes to
203
+ * `onOpen`. For taps on a killed app use `definePushActionTask` from
204
+ * `/push/task`, which sets this handler too. Returns a function that removes it.
205
+ */
206
+ export declare const onAction: (handler: PushActionHandler) => (() => void);
207
+ /** Shows a notification from the phone itself, for example when a button's request failed. */
208
+ export declare const notifyLocal: ({ title, body, data }: PushLocalNotification) => Promise<void>;
113
209
  /** Push status for a settings switch, re-rendering when it changes. */
114
210
  export declare const usePush: () => PushState & {
115
211
  enable: () => Promise<void>;
@@ -0,0 +1,15 @@
1
+ import { type PushActionHandler } from './push';
2
+ export type { PushAction, PushActionHandler } from './push';
3
+ /**
4
+ * Handles notification button taps in every app state, the killed app
5
+ * included. Call it once at module scope in the app's entry file (index.js),
6
+ * before the root component registers: a tap on a killed app starts the
7
+ * JavaScript bundle without the UI, so nothing later in the app runs.
8
+ *
9
+ * Android runs the handler from a background notification task
10
+ * (expo-task-manager). iOS has no such task for button taps: it launches the
11
+ * app in the background and the response reaches the listener this sets up.
12
+ * The handler's promise is awaited, so a request it makes can finish first.
13
+ */
14
+ export declare const definePushActionTask: (handler: PushActionHandler) => void;
15
+ //# sourceMappingURL=task.d.ts.map
@@ -1,3 +1,3 @@
1
- export type { PushAndroidChannel, PushConfig, PushDevice, PushModules, PushNotificationsLike, PushPlatform, PushState, PushStatus } from './push.js';
2
- export { disablePush, enablePush, initPush, setBadgeCount, setPushImplementation, usePush } from './push.js';
1
+ export type { PushAction, PushActionHandler, PushAndroidChannel, PushCategory, PushCategoryAction, PushConfig, PushDevice, PushLocalNotification, PushModules, PushNotificationsLike, PushPlatform, PushState, PushStatus } from './push.js';
2
+ export { disablePush, enablePush, initPush, notifyLocal, onAction, setBadgeCount, setCategories, setPushImplementation, usePush } from './push.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -26,6 +26,42 @@ export interface PushConfig {
26
26
  /** Whether a notification shown while the app is open plays its sound. Defaults to true. */
27
27
  playSoundInForeground?: () => boolean;
28
28
  }
29
+ /** A button on a notification category. */
30
+ export interface PushCategoryAction {
31
+ id: string;
32
+ title: string;
33
+ /** Whether the tap brings the app to the foreground. Defaults to true. */
34
+ opensApp?: boolean;
35
+ /** iOS only: the title shows in red. */
36
+ destructive?: boolean;
37
+ /** Turns the button into a reply box; the text arrives as `userText`. */
38
+ textInput?: {
39
+ submitTitle: string;
40
+ placeholder: string;
41
+ };
42
+ /** iOS only: the phone must be unlocked before the action runs. */
43
+ authenticationRequired?: boolean;
44
+ }
45
+ /** A set of buttons a push names through its category id. */
46
+ export interface PushCategory {
47
+ id: string;
48
+ actions: PushCategoryAction[];
49
+ }
50
+ /** A button tap on a notification. */
51
+ export interface PushAction {
52
+ actionId: string;
53
+ /** What the person typed, for a `textInput` action. */
54
+ userText?: string;
55
+ data: Record<string, string>;
56
+ /** The notification's identifier on the phone, not a server id in `data`. */
57
+ notificationId: string;
58
+ }
59
+ export type PushActionHandler = (action: PushAction) => Promise<void> | void;
60
+ export interface PushLocalNotification {
61
+ title: string;
62
+ body: string;
63
+ data?: Record<string, string>;
64
+ }
29
65
  export type PushStatus = 'unknown' | 'granted' | 'denied' | 'unavailable';
30
66
  export interface PushState {
31
67
  status: PushStatus;
@@ -41,15 +77,39 @@ interface SubscriptionLike {
41
77
  }
42
78
  interface NotificationResponseLike {
43
79
  actionIdentifier: string;
80
+ userText?: string;
44
81
  notification: {
45
82
  request: {
46
83
  identifier: string;
84
+ /** A background task receives the raw content, where data may still be a JSON `dataString`. */
47
85
  content: {
48
86
  data?: Record<string, unknown> | null;
87
+ dataString?: string;
49
88
  };
89
+ /** A remote push carries its raw payload: APNs `userInfo` on iOS, the FCM message on Android. */
90
+ trigger?: {
91
+ type?: string;
92
+ payload?: Record<string, unknown> | null;
93
+ remoteMessage?: {
94
+ data?: Record<string, unknown> | null;
95
+ } | null;
96
+ } | null;
50
97
  };
51
98
  };
52
99
  }
100
+ interface NotificationActionLike {
101
+ identifier: string;
102
+ buttonTitle: string;
103
+ textInput?: {
104
+ submitButtonTitle: string;
105
+ placeholder: string;
106
+ };
107
+ options: {
108
+ opensAppToForeground: boolean;
109
+ isDestructive: boolean;
110
+ isAuthenticationRequired: boolean;
111
+ };
112
+ }
53
113
  interface DevicePushTokenLike {
54
114
  type: string;
55
115
  data: unknown;
@@ -76,10 +136,22 @@ export interface PushNotificationsLike {
76
136
  importance: number;
77
137
  }) => Promise<unknown>;
78
138
  setBadgeCountAsync: (count: number) => Promise<boolean>;
139
+ setNotificationCategoryAsync: (identifier: string, actions: NotificationActionLike[]) => Promise<unknown>;
140
+ scheduleNotificationAsync: (request: {
141
+ content: {
142
+ title: string;
143
+ body: string;
144
+ data: Record<string, string>;
145
+ };
146
+ trigger: null;
147
+ }) => Promise<string>;
148
+ registerTaskAsync: (taskName: string) => Promise<unknown>;
79
149
  AndroidImportance: {
80
150
  DEFAULT: number;
81
151
  HIGH: number;
82
152
  };
153
+ /** The `actionIdentifier` of a plain tap on the notification body. */
154
+ DEFAULT_ACTION_IDENTIFIER: string;
83
155
  }
84
156
  export interface PushModules {
85
157
  notifications: PushNotificationsLike;
@@ -92,6 +164,16 @@ export interface PushModules {
92
164
  * included. Pass `null` to restore expo-notifications.
93
165
  */
94
166
  export declare const setPushImplementation: (impl: PushModules | null) => void;
167
+ /**
168
+ * The only place expo-notifications and expo-application are named. Expo Go is
169
+ * checked first because expo-notifications refuses remote push there and its
170
+ * import throws on Android. Exported for `./task`; not on the `/push` entry.
171
+ */
172
+ export declare const resolveModules: () => PushModules | null;
173
+ export declare const toPushAction: (response: NotificationResponseLike) => PushAction;
174
+ export declare const toNotificationActions: (actions: PushCategoryAction[]) => NotificationActionLike[];
175
+ /** The payload of the background notification task: routes button taps, ignores received pushes. */
176
+ export declare const routeTaskPayload: (payload: unknown) => Promise<void>;
95
177
  /**
96
178
  * Wires push at startup: the foreground handler, Android channels, and tap
97
179
  * handling, including the tap that launched the app. Calling it again only
@@ -110,6 +192,20 @@ export declare const enablePush: () => Promise<"granted" | "denied" | "unavailab
110
192
  export declare const disablePush: () => Promise<void>;
111
193
  /** Sets the app icon badge; a no-op where push is unavailable or the launcher has no badge. */
112
194
  export declare const setBadgeCount: (count: number) => Promise<void>;
195
+ /**
196
+ * Registers the button sets a push can name by category id. Call it at every
197
+ * start, with the same ids on both platforms. On Android the buttons show only
198
+ * on notifications expo-notifications draws, such as `notifyLocal`.
199
+ */
200
+ export declare const setCategories: (categories: PushCategory[]) => Promise<void>;
201
+ /**
202
+ * Handles button taps while JavaScript is running; a plain tap still goes to
203
+ * `onOpen`. For taps on a killed app use `definePushActionTask` from
204
+ * `/push/task`, which sets this handler too. Returns a function that removes it.
205
+ */
206
+ export declare const onAction: (handler: PushActionHandler) => (() => void);
207
+ /** Shows a notification from the phone itself, for example when a button's request failed. */
208
+ export declare const notifyLocal: ({ title, body, data }: PushLocalNotification) => Promise<void>;
113
209
  /** Push status for a settings switch, re-rendering when it changes. */
114
210
  export declare const usePush: () => PushState & {
115
211
  enable: () => Promise<void>;
@@ -0,0 +1,15 @@
1
+ import { type PushActionHandler } from './push.js';
2
+ export type { PushAction, PushActionHandler } from './push.js';
3
+ /**
4
+ * Handles notification button taps in every app state, the killed app
5
+ * included. Call it once at module scope in the app's entry file (index.js),
6
+ * before the root component registers: a tap on a killed app starts the
7
+ * JavaScript bundle without the UI, so nothing later in the app runs.
8
+ *
9
+ * Android runs the handler from a background notification task
10
+ * (expo-task-manager). iOS has no such task for button taps: it launches the
11
+ * app in the background and the response reaches the listener this sets up.
12
+ * The handler's promise is awaited, so a request it makes can finish first.
13
+ */
14
+ export declare const definePushActionTask: (handler: PushActionHandler) => void;
15
+ //# sourceMappingURL=task.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webority/mobile-core",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "Platform layer for Webority React Native apps: HTTP clients, auth/token storage, config, logging, formatters, validators, network status, permissions, storage and version checks. No UI.",
5
5
  "keywords": [
6
6
  "react-native",
@@ -171,6 +171,16 @@
171
171
  "default": "./lib/commonjs/push/index.js"
172
172
  }
173
173
  },
174
+ "./push/task": {
175
+ "import": {
176
+ "types": "./lib/typescript/module/push/task.d.ts",
177
+ "default": "./lib/module/push/task.js"
178
+ },
179
+ "require": {
180
+ "types": "./lib/typescript/commonjs/push/task.d.ts",
181
+ "default": "./lib/commonjs/push/task.js"
182
+ }
183
+ },
174
184
  "./smsRetriever": {
175
185
  "import": {
176
186
  "types": "./lib/typescript/module/smsRetriever/index.d.ts",
@@ -242,6 +252,7 @@
242
252
  "expo-secure-store": ">=57.0.0",
243
253
  "expo-sharing": ">=57.0.0",
244
254
  "expo-sqlite": ">=57.0.0",
255
+ "expo-task-manager": ">=57.0.0",
245
256
  "react": "^19.1.0",
246
257
  "react-native": ">=0.81.0"
247
258
  },
@@ -275,6 +286,9 @@
275
286
  },
276
287
  "expo-sharing": {
277
288
  "optional": true
289
+ },
290
+ "expo-task-manager": {
291
+ "optional": true
278
292
  }
279
293
  },
280
294
  "publishConfig": {