@webority/mobile-core 0.0.23 → 0.0.25

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
@@ -29,11 +29,21 @@ import { Geolocation } from '@webority/mobile-core/geolocation';
29
29
  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
+ import { initPush, enablePush, usePush } from '@webority/mobile-core/push';
32
33
  import { compressImage } from '@webority/mobile-core/compressor';
33
34
  ```
34
35
 
35
36
  Metro resolves requires statically, so anything reachable from the main barrel is bundled for
36
37
  every consumer. Keeping these off it is what makes their peers genuinely optional.
37
38
 
39
+ `/push` registers the phone for push notifications. It needs `expo-notifications`, an optional
40
+ peer, and reports `unavailable` without it or in Expo Go. Call `initPush` once at startup with
41
+ `getInstallationId`, `register`, `unregister` and `onOpen`; `enablePush` asks permission, sends
42
+ the native token (`Fcm` on Android, `Apns` on iOS) and the bundle id to `register`, and
43
+ re-registers when the token changes; `disablePush` calls `unregister`. `onOpen` receives the
44
+ tapped notification's data as strings, including the tap that launched the app. `usePush` gives a
45
+ 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
+
38
48
  Storage and network status stay on the main barrel but load no package on their own. Wire them at
39
49
  startup with `setStorageImplementation` and `setNetworkStatusImplementation` if you want them.
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.sendFormDataAsMultipart = void 0;
7
+ /**
8
+ * The clients default to a JSON content type, and axios then serialises a FormData body to
9
+ * JSON, which breaks every upload. Dropping the header for FormData lets the platform write
10
+ * the multipart header with its boundary.
11
+ */
12
+ const sendFormDataAsMultipart = client => {
13
+ client.interceptors.request.use(config => {
14
+ if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
15
+ config.headers?.delete?.('Content-Type');
16
+ }
17
+ return config;
18
+ });
19
+ };
20
+ exports.sendFormDataAsMultipart = sendFormDataAsMultipart;
21
+ //# sourceMappingURL=multipart.js.map
@@ -9,6 +9,7 @@ var _index = require("../config/index.js");
9
9
  var _index2 = require("../logger/index.js");
10
10
  var _baseUrl = require("./baseUrl.js");
11
11
  var _curl = require("./curl.js");
12
+ var _multipart = require("./multipart.js");
12
13
  var _retry = require("./retry.js");
13
14
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
14
15
  // Define timeout for requests
@@ -30,6 +31,7 @@ const publicClient = _axios.default.create({
30
31
  timeout: REQUEST_TIMEOUT
31
32
  });
32
33
  (0, _baseUrl.applyConfiguredBaseUrl)(publicClient);
34
+ (0, _multipart.sendFormDataAsMultipart)(publicClient);
33
35
 
34
36
  // Registered before the logging interceptors below so they only ever observe
35
37
  // the final outcome of a request, not its intermediate retries. The policy
@@ -29,6 +29,7 @@ var _index2 = require("../logger/index.js");
29
29
  var _networkStatus = require("../network/networkStatus.js");
30
30
  var _baseUrl = require("./baseUrl.js");
31
31
  var _curl = require("./curl.js");
32
+ var _multipart = require("./multipart.js");
32
33
  var _retry = require("./retry.js");
33
34
  var _tokenRefresh = require("./tokenRefresh.js");
34
35
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
@@ -135,6 +136,7 @@ const isRefreshTokenRequest = url => {
135
136
  // registered run last. The curl line therefore describes a request that already
136
137
  // has both its baseURL and its Authorization header.
137
138
  (0, _baseUrl.applyConfiguredBaseUrl)(userClient);
139
+ (0, _multipart.sendFormDataAsMultipart)(userClient);
138
140
 
139
141
  // Registered BEFORE the auth interceptor below deliberately. Axios builds its
140
142
  // request chain by unshifting, so the FIRST-registered request interceptor runs
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ Object.defineProperty(exports, "disablePush", {
7
+ enumerable: true,
8
+ get: function () {
9
+ return _push.disablePush;
10
+ }
11
+ });
12
+ Object.defineProperty(exports, "enablePush", {
13
+ enumerable: true,
14
+ get: function () {
15
+ return _push.enablePush;
16
+ }
17
+ });
18
+ Object.defineProperty(exports, "initPush", {
19
+ enumerable: true,
20
+ get: function () {
21
+ return _push.initPush;
22
+ }
23
+ });
24
+ Object.defineProperty(exports, "setBadgeCount", {
25
+ enumerable: true,
26
+ get: function () {
27
+ return _push.setBadgeCount;
28
+ }
29
+ });
30
+ Object.defineProperty(exports, "setPushImplementation", {
31
+ enumerable: true,
32
+ get: function () {
33
+ return _push.setPushImplementation;
34
+ }
35
+ });
36
+ Object.defineProperty(exports, "usePush", {
37
+ enumerable: true,
38
+ get: function () {
39
+ return _push.usePush;
40
+ }
41
+ });
42
+ var _push = require("./push.js");
43
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.usePush = exports.setPushImplementation = exports.setBadgeCount = exports.initPush = exports.enablePush = exports.disablePush = void 0;
7
+ var _react = require("react");
8
+ var _reactNative = require("react-native");
9
+ var _index = require("../logger/index.js");
10
+ /** The slice of expo-notifications this module calls, so the optional peer stays untyped at build. */
11
+
12
+ let override = null;
13
+
14
+ /** Expo modules resolved once; null when push cannot work in this build. */
15
+ let resolved;
16
+
17
+ /**
18
+ * Replace the native modules. For tests and the showcase, to reach paths a
19
+ * real device never takes; an injected backend is taken on trust, Expo Go
20
+ * included. Pass `null` to restore expo-notifications.
21
+ */
22
+ const setPushImplementation = impl => {
23
+ override = impl;
24
+ };
25
+
26
+ /**
27
+ * The only place expo-notifications and expo-application are named. Expo Go is
28
+ * checked first because expo-notifications refuses remote push there and its
29
+ * import throws on Android.
30
+ */
31
+ exports.setPushImplementation = setPushImplementation;
32
+ const resolveModules = () => {
33
+ if (override) {
34
+ return override;
35
+ }
36
+ if (resolved !== undefined) {
37
+ return resolved;
38
+ }
39
+ try {
40
+ const expo = require('expo');
41
+ if (expo.isRunningInExpoGo()) {
42
+ resolved = null;
43
+ return resolved;
44
+ }
45
+ const notifications = require('expo-notifications');
46
+ const application = require('expo-application');
47
+ resolved = {
48
+ notifications,
49
+ applicationId: application.applicationId
50
+ };
51
+ } catch (error) {
52
+ _index.Logger.warn('[push] expo-notifications is not available; push is off', error);
53
+ resolved = null;
54
+ }
55
+ return resolved;
56
+ };
57
+ let state = {
58
+ status: 'unknown',
59
+ enabled: false
60
+ };
61
+ const subscribers = new Set();
62
+ const setState = next => {
63
+ state = {
64
+ ...state,
65
+ ...next
66
+ };
67
+ for (const notify of subscribers) {
68
+ notify();
69
+ }
70
+ };
71
+ let config = null;
72
+ let channelsReady = Promise.resolve();
73
+ let tokenSubscription = null;
74
+ let lastOpenedId = null;
75
+ const requireConfig = () => {
76
+ if (!config) {
77
+ throw new Error('[@webority/mobile-core] call initPush before enablePush or disablePush.');
78
+ }
79
+ return config;
80
+ };
81
+ const toPermissionStatus = response => {
82
+ if (response.granted) {
83
+ return 'granted';
84
+ }
85
+ return response.status === 'denied' ? 'denied' : 'unknown';
86
+ };
87
+
88
+ /** Push data travels as strings; iOS can hand back numbers or objects for top-level keys. */
89
+ const toStringData = data => {
90
+ const out = {};
91
+ for (const [key, value] of Object.entries(data ?? {})) {
92
+ if (value === null || value === undefined) {
93
+ continue;
94
+ }
95
+ out[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
96
+ }
97
+ return out;
98
+ };
99
+ const handleResponse = response => {
100
+ if (!response || !config) {
101
+ return;
102
+ }
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) {
106
+ return;
107
+ }
108
+ lastOpenedId = id;
109
+ try {
110
+ config.onOpen(toStringData(response.notification.request.content.data));
111
+ } catch (error) {
112
+ _index.Logger.error('[push] onOpen threw', error);
113
+ }
114
+ };
115
+ const createChannels = async (m, channels) => {
116
+ for (const channel of channels) {
117
+ try {
118
+ await m.setNotificationChannelAsync(channel.id, {
119
+ name: channel.name,
120
+ importance: channel.importance === 'high' ? m.AndroidImportance.HIGH : m.AndroidImportance.DEFAULT
121
+ });
122
+ } catch (error) {
123
+ _index.Logger.error(`[push] creating channel ${channel.id} failed`, error);
124
+ }
125
+ }
126
+ };
127
+ const toDevice = async (token, applicationId) => {
128
+ const platform = token.type === 'android' ? 'Fcm' : token.type === 'ios' ? 'Apns' : null;
129
+ if (!platform || typeof token.data !== 'string') {
130
+ return null;
131
+ }
132
+ if (!applicationId) {
133
+ throw new Error('[@webority/mobile-core] push needs the app bundle id from expo-application.');
134
+ }
135
+ const installationId = await requireConfig().getInstallationId();
136
+ return {
137
+ installationId,
138
+ platform,
139
+ token: token.data,
140
+ appId: applicationId
141
+ };
142
+ };
143
+
144
+ /**
145
+ * Wires push at startup: the foreground handler, Android channels, and tap
146
+ * handling, including the tap that launched the app. Calling it again only
147
+ * replaces the config.
148
+ */
149
+ const initPush = next => {
150
+ const first = config === null;
151
+ config = next;
152
+ if (!first) {
153
+ return;
154
+ }
155
+ const mods = resolveModules();
156
+ if (!mods) {
157
+ setState({
158
+ status: 'unavailable'
159
+ });
160
+ return;
161
+ }
162
+ const m = mods.notifications;
163
+ m.setNotificationHandler({
164
+ handleNotification: async () => {
165
+ const show = config?.showInForeground?.() ?? true;
166
+ return {
167
+ shouldShowBanner: show,
168
+ shouldShowList: show,
169
+ shouldPlaySound: show && (config?.playSoundInForeground?.() ?? true),
170
+ shouldSetBadge: true
171
+ };
172
+ }
173
+ });
174
+ if (_reactNative.Platform.OS === 'android' && next.androidChannels?.length) {
175
+ channelsReady = createChannels(m, next.androidChannels);
176
+ }
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));
182
+ void m.getPermissionsAsync().then(response => setState({
183
+ status: toPermissionStatus(response)
184
+ })).catch(error => _index.Logger.error('[push] permission check failed', error));
185
+ };
186
+
187
+ /**
188
+ * Asks for permission, reads the native device token and registers it through
189
+ * `config.register`, then re-registers whenever the OS rotates the token.
190
+ */
191
+ exports.initPush = initPush;
192
+ const enablePush = async () => {
193
+ requireConfig();
194
+ const mods = resolveModules();
195
+ if (!mods) {
196
+ setState({
197
+ status: 'unavailable'
198
+ });
199
+ return 'unavailable';
200
+ }
201
+ const m = mods.notifications;
202
+
203
+ // Android 13 shows the permission prompt only once a channel exists.
204
+ await channelsReady;
205
+ let permission = await m.getPermissionsAsync();
206
+ if (!permission.granted) {
207
+ permission = await m.requestPermissionsAsync();
208
+ }
209
+ if (!permission.granted) {
210
+ setState({
211
+ status: 'denied'
212
+ });
213
+ return 'denied';
214
+ }
215
+ const device = await toDevice(await m.getDevicePushTokenAsync(), mods.applicationId);
216
+ if (!device) {
217
+ setState({
218
+ status: 'unavailable'
219
+ });
220
+ return 'unavailable';
221
+ }
222
+ await requireConfig().register(device);
223
+ tokenSubscription?.remove();
224
+ tokenSubscription = m.addPushTokenListener(token => {
225
+ void toDevice(token, mods.applicationId).then(next => next ? requireConfig().register(next) : undefined).catch(error => _index.Logger.error('[push] re-registering a new token failed', error));
226
+ });
227
+ setState({
228
+ status: 'granted',
229
+ enabled: true
230
+ });
231
+ return 'granted';
232
+ };
233
+
234
+ /**
235
+ * Unregisters the device through `config.unregister` and stops following token
236
+ * changes. Taps on notifications already delivered still reach `onOpen`.
237
+ */
238
+ exports.enablePush = enablePush;
239
+ const disablePush = async () => {
240
+ const current = requireConfig();
241
+ // Stop following token changes first, so a failed unregister cannot re-register the phone.
242
+ tokenSubscription?.remove();
243
+ tokenSubscription = null;
244
+ setState({
245
+ enabled: false
246
+ });
247
+ await current.unregister(await current.getInstallationId());
248
+ };
249
+
250
+ /** Sets the app icon badge; a no-op where push is unavailable or the launcher has no badge. */
251
+ exports.disablePush = disablePush;
252
+ const setBadgeCount = async count => {
253
+ const mods = resolveModules();
254
+ if (!mods) {
255
+ return;
256
+ }
257
+ await mods.notifications.setBadgeCountAsync(count);
258
+ };
259
+ exports.setBadgeCount = setBadgeCount;
260
+ const subscribe = notify => {
261
+ subscribers.add(notify);
262
+ return () => {
263
+ subscribers.delete(notify);
264
+ };
265
+ };
266
+ const getSnapshot = () => state;
267
+ const enable = async () => {
268
+ await enablePush();
269
+ };
270
+
271
+ /** Push status for a settings switch, re-rendering when it changes. */
272
+ const usePush = () => {
273
+ const snapshot = (0, _react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
274
+ return {
275
+ ...snapshot,
276
+ enable,
277
+ disable: disablePush
278
+ };
279
+ };
280
+ exports.usePush = usePush;
281
+ //# sourceMappingURL=push.js.map
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The clients default to a JSON content type, and axios then serialises a FormData body to
5
+ * JSON, which breaks every upload. Dropping the header for FormData lets the platform write
6
+ * the multipart header with its boundary.
7
+ */
8
+ export const sendFormDataAsMultipart = client => {
9
+ client.interceptors.request.use(config => {
10
+ if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
11
+ config.headers?.delete?.('Content-Type');
12
+ }
13
+ return config;
14
+ });
15
+ };
16
+ //# sourceMappingURL=multipart.js.map
@@ -5,6 +5,7 @@ import { getConfigValue } from "../config/index.js";
5
5
  import { Logger } from "../logger/index.js";
6
6
  import { applyConfiguredBaseUrl } from "./baseUrl.js";
7
7
  import { logCurl } from "./curl.js";
8
+ import { sendFormDataAsMultipart } from "./multipart.js";
8
9
  import { attachRetry } from "./retry.js";
9
10
 
10
11
  // Define timeout for requests
@@ -26,6 +27,7 @@ const publicClient = axios.create({
26
27
  timeout: REQUEST_TIMEOUT
27
28
  });
28
29
  applyConfiguredBaseUrl(publicClient);
30
+ sendFormDataAsMultipart(publicClient);
29
31
 
30
32
  // Registered before the logging interceptors below so they only ever observe
31
33
  // the final outcome of a request, not its intermediate retries. The policy
@@ -7,6 +7,7 @@ import { Logger } from "../logger/index.js";
7
7
  import { getNetworkStatus } from "../network/networkStatus.js";
8
8
  import { applyConfiguredBaseUrl } from "./baseUrl.js";
9
9
  import { logCurl } from "./curl.js";
10
+ import { sendFormDataAsMultipart } from "./multipart.js";
10
11
  import { attachRetry } from "./retry.js";
11
12
  import { dispatchLogout, getErrorMessage, getRefreshPath, isRefreshInFlight, queueBehindRefresh, refreshAccessToken, resetRefreshState } from "./tokenRefresh.js";
12
13
  export { injectStore, injectUserAuth, resetRefreshState } from "./tokenRefresh.js";
@@ -113,6 +114,7 @@ const isRefreshTokenRequest = url => {
113
114
  // registered run last. The curl line therefore describes a request that already
114
115
  // has both its baseURL and its Authorization header.
115
116
  applyConfiguredBaseUrl(userClient);
117
+ sendFormDataAsMultipart(userClient);
116
118
 
117
119
  // Registered BEFORE the auth interceptor below deliberately. Axios builds its
118
120
  // request chain by unshifting, so the FIRST-registered request interceptor runs
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+
3
+ export { disablePush, enablePush, initPush, setBadgeCount, setPushImplementation, usePush } from "./push.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+ import { Platform } from 'react-native';
5
+ import { Logger } from "../logger/index.js";
6
+
7
+ /** The slice of expo-notifications this module calls, so the optional peer stays untyped at build. */
8
+
9
+ let override = null;
10
+
11
+ /** Expo modules resolved once; null when push cannot work in this build. */
12
+ let resolved;
13
+
14
+ /**
15
+ * Replace the native modules. For tests and the showcase, to reach paths a
16
+ * real device never takes; an injected backend is taken on trust, Expo Go
17
+ * included. Pass `null` to restore expo-notifications.
18
+ */
19
+ export const setPushImplementation = impl => {
20
+ override = impl;
21
+ };
22
+
23
+ /**
24
+ * The only place expo-notifications and expo-application are named. Expo Go is
25
+ * checked first because expo-notifications refuses remote push there and its
26
+ * import throws on Android.
27
+ */
28
+ const resolveModules = () => {
29
+ if (override) {
30
+ return override;
31
+ }
32
+ if (resolved !== undefined) {
33
+ return resolved;
34
+ }
35
+ try {
36
+ const expo = require('expo');
37
+ if (expo.isRunningInExpoGo()) {
38
+ resolved = null;
39
+ return resolved;
40
+ }
41
+ const notifications = require('expo-notifications');
42
+ const application = require('expo-application');
43
+ resolved = {
44
+ notifications,
45
+ applicationId: application.applicationId
46
+ };
47
+ } catch (error) {
48
+ Logger.warn('[push] expo-notifications is not available; push is off', error);
49
+ resolved = null;
50
+ }
51
+ return resolved;
52
+ };
53
+ let state = {
54
+ status: 'unknown',
55
+ enabled: false
56
+ };
57
+ const subscribers = new Set();
58
+ const setState = next => {
59
+ state = {
60
+ ...state,
61
+ ...next
62
+ };
63
+ for (const notify of subscribers) {
64
+ notify();
65
+ }
66
+ };
67
+ let config = null;
68
+ let channelsReady = Promise.resolve();
69
+ let tokenSubscription = null;
70
+ let lastOpenedId = null;
71
+ const requireConfig = () => {
72
+ if (!config) {
73
+ throw new Error('[@webority/mobile-core] call initPush before enablePush or disablePush.');
74
+ }
75
+ return config;
76
+ };
77
+ const toPermissionStatus = response => {
78
+ if (response.granted) {
79
+ return 'granted';
80
+ }
81
+ return response.status === 'denied' ? 'denied' : 'unknown';
82
+ };
83
+
84
+ /** Push data travels as strings; iOS can hand back numbers or objects for top-level keys. */
85
+ const toStringData = data => {
86
+ const out = {};
87
+ for (const [key, value] of Object.entries(data ?? {})) {
88
+ if (value === null || value === undefined) {
89
+ continue;
90
+ }
91
+ out[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
92
+ }
93
+ return out;
94
+ };
95
+ const handleResponse = response => {
96
+ if (!response || !config) {
97
+ return;
98
+ }
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) {
102
+ return;
103
+ }
104
+ lastOpenedId = id;
105
+ try {
106
+ config.onOpen(toStringData(response.notification.request.content.data));
107
+ } catch (error) {
108
+ Logger.error('[push] onOpen threw', error);
109
+ }
110
+ };
111
+ const createChannels = async (m, channels) => {
112
+ for (const channel of channels) {
113
+ try {
114
+ await m.setNotificationChannelAsync(channel.id, {
115
+ name: channel.name,
116
+ importance: channel.importance === 'high' ? m.AndroidImportance.HIGH : m.AndroidImportance.DEFAULT
117
+ });
118
+ } catch (error) {
119
+ Logger.error(`[push] creating channel ${channel.id} failed`, error);
120
+ }
121
+ }
122
+ };
123
+ const toDevice = async (token, applicationId) => {
124
+ const platform = token.type === 'android' ? 'Fcm' : token.type === 'ios' ? 'Apns' : null;
125
+ if (!platform || typeof token.data !== 'string') {
126
+ return null;
127
+ }
128
+ if (!applicationId) {
129
+ throw new Error('[@webority/mobile-core] push needs the app bundle id from expo-application.');
130
+ }
131
+ const installationId = await requireConfig().getInstallationId();
132
+ return {
133
+ installationId,
134
+ platform,
135
+ token: token.data,
136
+ appId: applicationId
137
+ };
138
+ };
139
+
140
+ /**
141
+ * Wires push at startup: the foreground handler, Android channels, and tap
142
+ * handling, including the tap that launched the app. Calling it again only
143
+ * replaces the config.
144
+ */
145
+ export const initPush = next => {
146
+ const first = config === null;
147
+ config = next;
148
+ if (!first) {
149
+ return;
150
+ }
151
+ const mods = resolveModules();
152
+ if (!mods) {
153
+ setState({
154
+ status: 'unavailable'
155
+ });
156
+ return;
157
+ }
158
+ const m = mods.notifications;
159
+ m.setNotificationHandler({
160
+ handleNotification: async () => {
161
+ const show = config?.showInForeground?.() ?? true;
162
+ return {
163
+ shouldShowBanner: show,
164
+ shouldShowList: show,
165
+ shouldPlaySound: show && (config?.playSoundInForeground?.() ?? true),
166
+ shouldSetBadge: true
167
+ };
168
+ }
169
+ });
170
+ if (Platform.OS === 'android' && next.androidChannels?.length) {
171
+ channelsReady = createChannels(m, next.androidChannels);
172
+ }
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));
178
+ void m.getPermissionsAsync().then(response => setState({
179
+ status: toPermissionStatus(response)
180
+ })).catch(error => Logger.error('[push] permission check failed', error));
181
+ };
182
+
183
+ /**
184
+ * Asks for permission, reads the native device token and registers it through
185
+ * `config.register`, then re-registers whenever the OS rotates the token.
186
+ */
187
+ export const enablePush = async () => {
188
+ requireConfig();
189
+ const mods = resolveModules();
190
+ if (!mods) {
191
+ setState({
192
+ status: 'unavailable'
193
+ });
194
+ return 'unavailable';
195
+ }
196
+ const m = mods.notifications;
197
+
198
+ // Android 13 shows the permission prompt only once a channel exists.
199
+ await channelsReady;
200
+ let permission = await m.getPermissionsAsync();
201
+ if (!permission.granted) {
202
+ permission = await m.requestPermissionsAsync();
203
+ }
204
+ if (!permission.granted) {
205
+ setState({
206
+ status: 'denied'
207
+ });
208
+ return 'denied';
209
+ }
210
+ const device = await toDevice(await m.getDevicePushTokenAsync(), mods.applicationId);
211
+ if (!device) {
212
+ setState({
213
+ status: 'unavailable'
214
+ });
215
+ return 'unavailable';
216
+ }
217
+ await requireConfig().register(device);
218
+ tokenSubscription?.remove();
219
+ tokenSubscription = m.addPushTokenListener(token => {
220
+ void toDevice(token, mods.applicationId).then(next => next ? requireConfig().register(next) : undefined).catch(error => Logger.error('[push] re-registering a new token failed', error));
221
+ });
222
+ setState({
223
+ status: 'granted',
224
+ enabled: true
225
+ });
226
+ return 'granted';
227
+ };
228
+
229
+ /**
230
+ * Unregisters the device through `config.unregister` and stops following token
231
+ * changes. Taps on notifications already delivered still reach `onOpen`.
232
+ */
233
+ export const disablePush = async () => {
234
+ const current = requireConfig();
235
+ // Stop following token changes first, so a failed unregister cannot re-register the phone.
236
+ tokenSubscription?.remove();
237
+ tokenSubscription = null;
238
+ setState({
239
+ enabled: false
240
+ });
241
+ await current.unregister(await current.getInstallationId());
242
+ };
243
+
244
+ /** Sets the app icon badge; a no-op where push is unavailable or the launcher has no badge. */
245
+ export const setBadgeCount = async count => {
246
+ const mods = resolveModules();
247
+ if (!mods) {
248
+ return;
249
+ }
250
+ await mods.notifications.setBadgeCountAsync(count);
251
+ };
252
+ const subscribe = notify => {
253
+ subscribers.add(notify);
254
+ return () => {
255
+ subscribers.delete(notify);
256
+ };
257
+ };
258
+ const getSnapshot = () => state;
259
+ const enable = async () => {
260
+ await enablePush();
261
+ };
262
+
263
+ /** Push status for a settings switch, re-rendering when it changes. */
264
+ export const usePush = () => {
265
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
266
+ return {
267
+ ...snapshot,
268
+ enable,
269
+ disable: disablePush
270
+ };
271
+ };
272
+ //# sourceMappingURL=push.js.map
@@ -0,0 +1,8 @@
1
+ import type { AxiosInstance } from 'axios';
2
+ /**
3
+ * The clients default to a JSON content type, and axios then serialises a FormData body to
4
+ * JSON, which breaks every upload. Dropping the header for FormData lets the platform write
5
+ * the multipart header with its boundary.
6
+ */
7
+ export declare const sendFormDataAsMultipart: (client: AxiosInstance) => void;
8
+ //# sourceMappingURL=multipart.d.ts.map
@@ -0,0 +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';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,119 @@
1
+ export type PushPlatform = 'Fcm' | 'Apns';
2
+ export interface PushDevice {
3
+ installationId: string;
4
+ platform: PushPlatform;
5
+ token: string;
6
+ /** The app's bundle id, the APNs topic on iOS. */
7
+ appId: string;
8
+ }
9
+ export interface PushAndroidChannel {
10
+ id: string;
11
+ name: string;
12
+ importance?: 'default' | 'high';
13
+ }
14
+ export interface PushConfig {
15
+ getInstallationId: () => Promise<string> | string;
16
+ /** Sends the device to the app's own API. */
17
+ register: (device: PushDevice) => Promise<void>;
18
+ /** Removes the device from the app's own API. */
19
+ unregister: (installationId: string) => Promise<void>;
20
+ /** A notification was tapped, including the tap that launched the app. */
21
+ onOpen: (data: Record<string, string>) => void;
22
+ /** Only for channels the app does not create natively. */
23
+ androidChannels?: PushAndroidChannel[];
24
+ /** Whether a notification arriving while the app is open is shown. Defaults to true. */
25
+ showInForeground?: () => boolean;
26
+ /** Whether a notification shown while the app is open plays its sound. Defaults to true. */
27
+ playSoundInForeground?: () => boolean;
28
+ }
29
+ export type PushStatus = 'unknown' | 'granted' | 'denied' | 'unavailable';
30
+ export interface PushState {
31
+ status: PushStatus;
32
+ /** True once this process has registered the device, false after `disablePush`. */
33
+ enabled: boolean;
34
+ }
35
+ interface PermissionResponseLike {
36
+ status: 'granted' | 'denied' | 'undetermined';
37
+ granted: boolean;
38
+ }
39
+ interface SubscriptionLike {
40
+ remove: () => void;
41
+ }
42
+ interface NotificationResponseLike {
43
+ actionIdentifier: string;
44
+ notification: {
45
+ request: {
46
+ identifier: string;
47
+ content: {
48
+ data?: Record<string, unknown> | null;
49
+ };
50
+ };
51
+ };
52
+ }
53
+ interface DevicePushTokenLike {
54
+ type: string;
55
+ data: unknown;
56
+ }
57
+ /** The slice of expo-notifications this module calls, so the optional peer stays untyped at build. */
58
+ export interface PushNotificationsLike {
59
+ getPermissionsAsync: () => Promise<PermissionResponseLike>;
60
+ requestPermissionsAsync: () => Promise<PermissionResponseLike>;
61
+ getDevicePushTokenAsync: () => Promise<DevicePushTokenLike>;
62
+ addPushTokenListener: (listener: (token: DevicePushTokenLike) => void) => SubscriptionLike;
63
+ addNotificationResponseReceivedListener: (listener: (response: NotificationResponseLike) => void) => SubscriptionLike;
64
+ getLastNotificationResponseAsync: () => Promise<NotificationResponseLike | null>;
65
+ clearLastNotificationResponseAsync?: () => Promise<void>;
66
+ setNotificationHandler: (handler: {
67
+ handleNotification: () => Promise<{
68
+ shouldShowBanner: boolean;
69
+ shouldShowList: boolean;
70
+ shouldPlaySound: boolean;
71
+ shouldSetBadge: boolean;
72
+ }>;
73
+ } | null) => void;
74
+ setNotificationChannelAsync: (id: string, channel: {
75
+ name: string;
76
+ importance: number;
77
+ }) => Promise<unknown>;
78
+ setBadgeCountAsync: (count: number) => Promise<boolean>;
79
+ AndroidImportance: {
80
+ DEFAULT: number;
81
+ HIGH: number;
82
+ };
83
+ }
84
+ export interface PushModules {
85
+ notifications: PushNotificationsLike;
86
+ /** The bundle id, from expo-application. */
87
+ applicationId: string | null;
88
+ }
89
+ /**
90
+ * Replace the native modules. For tests and the showcase, to reach paths a
91
+ * real device never takes; an injected backend is taken on trust, Expo Go
92
+ * included. Pass `null` to restore expo-notifications.
93
+ */
94
+ export declare const setPushImplementation: (impl: PushModules | null) => void;
95
+ /**
96
+ * Wires push at startup: the foreground handler, Android channels, and tap
97
+ * handling, including the tap that launched the app. Calling it again only
98
+ * replaces the config.
99
+ */
100
+ export declare const initPush: (next: PushConfig) => void;
101
+ /**
102
+ * Asks for permission, reads the native device token and registers it through
103
+ * `config.register`, then re-registers whenever the OS rotates the token.
104
+ */
105
+ export declare const enablePush: () => Promise<"granted" | "denied" | "unavailable">;
106
+ /**
107
+ * Unregisters the device through `config.unregister` and stops following token
108
+ * changes. Taps on notifications already delivered still reach `onOpen`.
109
+ */
110
+ export declare const disablePush: () => Promise<void>;
111
+ /** Sets the app icon badge; a no-op where push is unavailable or the launcher has no badge. */
112
+ export declare const setBadgeCount: (count: number) => Promise<void>;
113
+ /** Push status for a settings switch, re-rendering when it changes. */
114
+ export declare const usePush: () => PushState & {
115
+ enable: () => Promise<void>;
116
+ disable: () => Promise<void>;
117
+ };
118
+ export {};
119
+ //# sourceMappingURL=push.d.ts.map
@@ -0,0 +1,8 @@
1
+ import type { AxiosInstance } from 'axios';
2
+ /**
3
+ * The clients default to a JSON content type, and axios then serialises a FormData body to
4
+ * JSON, which breaks every upload. Dropping the header for FormData lets the platform write
5
+ * the multipart header with its boundary.
6
+ */
7
+ export declare const sendFormDataAsMultipart: (client: AxiosInstance) => void;
8
+ //# sourceMappingURL=multipart.d.ts.map
@@ -0,0 +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';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,119 @@
1
+ export type PushPlatform = 'Fcm' | 'Apns';
2
+ export interface PushDevice {
3
+ installationId: string;
4
+ platform: PushPlatform;
5
+ token: string;
6
+ /** The app's bundle id, the APNs topic on iOS. */
7
+ appId: string;
8
+ }
9
+ export interface PushAndroidChannel {
10
+ id: string;
11
+ name: string;
12
+ importance?: 'default' | 'high';
13
+ }
14
+ export interface PushConfig {
15
+ getInstallationId: () => Promise<string> | string;
16
+ /** Sends the device to the app's own API. */
17
+ register: (device: PushDevice) => Promise<void>;
18
+ /** Removes the device from the app's own API. */
19
+ unregister: (installationId: string) => Promise<void>;
20
+ /** A notification was tapped, including the tap that launched the app. */
21
+ onOpen: (data: Record<string, string>) => void;
22
+ /** Only for channels the app does not create natively. */
23
+ androidChannels?: PushAndroidChannel[];
24
+ /** Whether a notification arriving while the app is open is shown. Defaults to true. */
25
+ showInForeground?: () => boolean;
26
+ /** Whether a notification shown while the app is open plays its sound. Defaults to true. */
27
+ playSoundInForeground?: () => boolean;
28
+ }
29
+ export type PushStatus = 'unknown' | 'granted' | 'denied' | 'unavailable';
30
+ export interface PushState {
31
+ status: PushStatus;
32
+ /** True once this process has registered the device, false after `disablePush`. */
33
+ enabled: boolean;
34
+ }
35
+ interface PermissionResponseLike {
36
+ status: 'granted' | 'denied' | 'undetermined';
37
+ granted: boolean;
38
+ }
39
+ interface SubscriptionLike {
40
+ remove: () => void;
41
+ }
42
+ interface NotificationResponseLike {
43
+ actionIdentifier: string;
44
+ notification: {
45
+ request: {
46
+ identifier: string;
47
+ content: {
48
+ data?: Record<string, unknown> | null;
49
+ };
50
+ };
51
+ };
52
+ }
53
+ interface DevicePushTokenLike {
54
+ type: string;
55
+ data: unknown;
56
+ }
57
+ /** The slice of expo-notifications this module calls, so the optional peer stays untyped at build. */
58
+ export interface PushNotificationsLike {
59
+ getPermissionsAsync: () => Promise<PermissionResponseLike>;
60
+ requestPermissionsAsync: () => Promise<PermissionResponseLike>;
61
+ getDevicePushTokenAsync: () => Promise<DevicePushTokenLike>;
62
+ addPushTokenListener: (listener: (token: DevicePushTokenLike) => void) => SubscriptionLike;
63
+ addNotificationResponseReceivedListener: (listener: (response: NotificationResponseLike) => void) => SubscriptionLike;
64
+ getLastNotificationResponseAsync: () => Promise<NotificationResponseLike | null>;
65
+ clearLastNotificationResponseAsync?: () => Promise<void>;
66
+ setNotificationHandler: (handler: {
67
+ handleNotification: () => Promise<{
68
+ shouldShowBanner: boolean;
69
+ shouldShowList: boolean;
70
+ shouldPlaySound: boolean;
71
+ shouldSetBadge: boolean;
72
+ }>;
73
+ } | null) => void;
74
+ setNotificationChannelAsync: (id: string, channel: {
75
+ name: string;
76
+ importance: number;
77
+ }) => Promise<unknown>;
78
+ setBadgeCountAsync: (count: number) => Promise<boolean>;
79
+ AndroidImportance: {
80
+ DEFAULT: number;
81
+ HIGH: number;
82
+ };
83
+ }
84
+ export interface PushModules {
85
+ notifications: PushNotificationsLike;
86
+ /** The bundle id, from expo-application. */
87
+ applicationId: string | null;
88
+ }
89
+ /**
90
+ * Replace the native modules. For tests and the showcase, to reach paths a
91
+ * real device never takes; an injected backend is taken on trust, Expo Go
92
+ * included. Pass `null` to restore expo-notifications.
93
+ */
94
+ export declare const setPushImplementation: (impl: PushModules | null) => void;
95
+ /**
96
+ * Wires push at startup: the foreground handler, Android channels, and tap
97
+ * handling, including the tap that launched the app. Calling it again only
98
+ * replaces the config.
99
+ */
100
+ export declare const initPush: (next: PushConfig) => void;
101
+ /**
102
+ * Asks for permission, reads the native device token and registers it through
103
+ * `config.register`, then re-registers whenever the OS rotates the token.
104
+ */
105
+ export declare const enablePush: () => Promise<"granted" | "denied" | "unavailable">;
106
+ /**
107
+ * Unregisters the device through `config.unregister` and stops following token
108
+ * changes. Taps on notifications already delivered still reach `onOpen`.
109
+ */
110
+ export declare const disablePush: () => Promise<void>;
111
+ /** Sets the app icon badge; a no-op where push is unavailable or the launcher has no badge. */
112
+ export declare const setBadgeCount: (count: number) => Promise<void>;
113
+ /** Push status for a settings switch, re-rendering when it changes. */
114
+ export declare const usePush: () => PushState & {
115
+ enable: () => Promise<void>;
116
+ disable: () => Promise<void>;
117
+ };
118
+ export {};
119
+ //# sourceMappingURL=push.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webority/mobile-core",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
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",
@@ -161,6 +161,16 @@
161
161
  "default": "./lib/commonjs/share/index.js"
162
162
  }
163
163
  },
164
+ "./push": {
165
+ "import": {
166
+ "types": "./lib/typescript/module/push/index.d.ts",
167
+ "default": "./lib/module/push/index.js"
168
+ },
169
+ "require": {
170
+ "types": "./lib/typescript/commonjs/push/index.d.ts",
171
+ "default": "./lib/commonjs/push/index.js"
172
+ }
173
+ },
164
174
  "./smsRetriever": {
165
175
  "import": {
166
176
  "types": "./lib/typescript/module/smsRetriever/index.d.ts",