@mpgd/adapter-capacitor 0.10.0 → 0.11.0
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 +126 -19
- package/dist/bridge-events.d.ts +7 -0
- package/dist/bridge-events.js +14 -0
- package/dist/commerce-bridge.js +2 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -12
- package/dist/notifications-bridge.d.ts +82 -0
- package/dist/notifications-bridge.js +333 -0
- package/dist/notifications-provider.d.ts +118 -0
- package/dist/notifications-provider.js +1023 -0
- package/dist/notifications-sdk.d.ts +166 -0
- package/dist/notifications-sdk.js +47 -0
- package/dist/notifications.d.ts +27 -0
- package/dist/notifications.js +35 -0
- package/dist/providers.d.ts +56 -3
- package/dist/providers.js +98 -18
- package/package.json +23 -9
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
import { App } from '@capacitor/app';
|
|
2
|
+
import { assertLocalNotificationRequest, isRootRelativeNotificationRoute, normalizeNotificationTimestamp, notificationAdvertisingOptOutActionId, NotificationError, } from '@mpgd/platform/notifications';
|
|
3
|
+
// The Capacitor notifications v2 provider (notifications RFC "Provider
|
|
4
|
+
// profiles", Capacitor): app-scope OS permission, FCM device endpoints, local
|
|
5
|
+
// reminders through `@capacitor/local-notifications`, and opens from the
|
|
6
|
+
// notification payload. It never sends and never grants: a remote push comes
|
|
7
|
+
// only from the backend, and an open is routing data.
|
|
8
|
+
/** The reference provider id the backend's FCM registry entry uses. */
|
|
9
|
+
export const capacitorNotificationProviderId = 'fcm';
|
|
10
|
+
/**
|
|
11
|
+
* The notification category (iOS) and action type that carries the
|
|
12
|
+
* advertising opt-out action. An FCM send for an advertising topic names it
|
|
13
|
+
* (`apns.payload.aps.category`) so iOS shows the action.
|
|
14
|
+
*/
|
|
15
|
+
export const capacitorAdvertisingActionTypeId = 'mpgd-advertising';
|
|
16
|
+
/**
|
|
17
|
+
* Keys of the push data the provider reads. A send carries the topic and the
|
|
18
|
+
* topic's root-relative route in them; every other string entry reaches the
|
|
19
|
+
* game as the open's untrusted `data`.
|
|
20
|
+
*/
|
|
21
|
+
export const capacitorNotificationDataKeys = Object.freeze({
|
|
22
|
+
topic: 'topic',
|
|
23
|
+
route: 'route',
|
|
24
|
+
});
|
|
25
|
+
/** iOS keeps at most 64 pending local notifications per app. */
|
|
26
|
+
export const capacitorIosMaxPendingLocalNotifications = 64;
|
|
27
|
+
/** How long `openSettings` waits for the app to leave for the settings screen. */
|
|
28
|
+
export const capacitorSettingsLeaveTimeoutMs = 5_000;
|
|
29
|
+
/** How long `openSettings` waits for the player to come back from settings. */
|
|
30
|
+
export const capacitorSettingsReturnTimeoutMs = 120_000;
|
|
31
|
+
const defaultChannels = {
|
|
32
|
+
service: { name: 'Notifications' },
|
|
33
|
+
requested: { name: 'Reminders' },
|
|
34
|
+
advertising: { name: 'Promotions' },
|
|
35
|
+
};
|
|
36
|
+
const extraKeys = Object.freeze({
|
|
37
|
+
key: 'mpgdKey',
|
|
38
|
+
topic: 'mpgdTopic',
|
|
39
|
+
fireAt: 'mpgdFireAt',
|
|
40
|
+
route: 'mpgdRoute',
|
|
41
|
+
});
|
|
42
|
+
/** Push data keys that are not the game's: the provider's own and the transport's. */
|
|
43
|
+
const reservedDataKey = /^(?:topic|route|from|collapse_key|aps|fcm_options)$|^(?:google|gcm)\./u;
|
|
44
|
+
const eventBufferLimit = 64;
|
|
45
|
+
const maxNativeId = 0x7f_ff_ff_ff;
|
|
46
|
+
/**
|
|
47
|
+
* The Android channel a topic posts to: its mapping's `channelId`, or the
|
|
48
|
+
* default channel of its purpose (`mpgd-service`, `mpgd-requested`,
|
|
49
|
+
* `mpgd-advertising`), so advertising stays in a channel of its own that the
|
|
50
|
+
* player can turn off in system settings.
|
|
51
|
+
*/
|
|
52
|
+
export function capacitorNotificationChannelId(topic) {
|
|
53
|
+
const mapped = topic.mapping?.['channelId'];
|
|
54
|
+
return typeof mapped === 'string' && mapped !== '' ? mapped : `mpgd-${topic.purpose}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The native id of a local reminder key: a stable positive 32-bit integer
|
|
58
|
+
* (FNV-1a), as Android requires. The provider probes the next id when another
|
|
59
|
+
* pending reminder already holds it, and finds reminders by the key it stores
|
|
60
|
+
* in `extra`, so a collision never replaces another reminder.
|
|
61
|
+
*/
|
|
62
|
+
export function capacitorLocalNotificationId(key) {
|
|
63
|
+
let hash = 0x81_1c_9d_c5;
|
|
64
|
+
for (const unit of new TextEncoder().encode(key)) {
|
|
65
|
+
hash ^= unit;
|
|
66
|
+
hash = Math.imul(hash, 0x01_00_01_93) >>> 0;
|
|
67
|
+
}
|
|
68
|
+
return (hash % maxNativeId) + 1;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Creates the Capacitor notifications provider over the two plugin SDKs.
|
|
72
|
+
*
|
|
73
|
+
* - **Listeners first.** It registers every native listener synchronously,
|
|
74
|
+
* before any `await`, and keeps them for the process lifetime: Capacitor
|
|
75
|
+
* retains a plugin's events only until its first native listener, so a
|
|
76
|
+
* cold-start open reaches this provider, which keeps it for its first
|
|
77
|
+
* subscriber.
|
|
78
|
+
* - **Permission.** It reads `LocalNotifications.checkPermissions`, which
|
|
79
|
+
* reflects the system setting on every Android version, and maps the OS
|
|
80
|
+
* `denied` to `blocked`: on iOS after the one denial, on Android after the
|
|
81
|
+
* second. Only `requestPermission` shows the OS dialog; `scheduleLocal`
|
|
82
|
+
* answers `permission-required` instead.
|
|
83
|
+
* - **Local reminders.** Inexact (`isExactNotification: false`), on the
|
|
84
|
+
* topic's channel, at most 64 pending on iOS, keeping the nearest, one
|
|
85
|
+
* operation at a time. A `fireAt` already past posts at once, as both
|
|
86
|
+
* plugins do.
|
|
87
|
+
* - **Fails closed.** Without either SDK the provider is `unsupported`;
|
|
88
|
+
* without `push` it declares no remote delivery, without `local` no local
|
|
89
|
+
* reminders.
|
|
90
|
+
*/
|
|
91
|
+
export function createCapacitorNotificationProvider(input) {
|
|
92
|
+
const { push, local, platform } = input;
|
|
93
|
+
const providerId = input.provider ?? capacitorNotificationProviderId;
|
|
94
|
+
const topics = [...input.topics];
|
|
95
|
+
const topicById = new Map(topics.map((topic) => [topic.id, topic]));
|
|
96
|
+
/**
|
|
97
|
+
* The topic this target maps. A topic the catalog declares without a
|
|
98
|
+
* mapping for this target, as `resolveNotificationTopicsForTarget` keeps
|
|
99
|
+
* it, is not available here: it reads `unsupported`, a request answers
|
|
100
|
+
* `unavailable`, it schedules nothing, gets no channel, and an open naming
|
|
101
|
+
* it carries no topic, as the reference runtime treats it.
|
|
102
|
+
*/
|
|
103
|
+
const mappedTopic = (id) => {
|
|
104
|
+
const declared = id === undefined ? undefined : topicById.get(id);
|
|
105
|
+
return declared?.mapping === undefined ? undefined : declared;
|
|
106
|
+
};
|
|
107
|
+
const mappedTopics = topics.filter((topic) => topic.mapping !== undefined);
|
|
108
|
+
const channels = collectChannels(mappedTopics, input.channels ?? {});
|
|
109
|
+
const unsupported = push === undefined && local === undefined;
|
|
110
|
+
// RFC "Advertising rules": an advertising push carries the opt-out action,
|
|
111
|
+
// which this provider registers through the local plugin (the iOS
|
|
112
|
+
// category, and the action type a shell renders on Android). Without it,
|
|
113
|
+
// remote advertising would go out without the action, so the provider is
|
|
114
|
+
// `configuration-required`, as a registry mismatch makes a provider.
|
|
115
|
+
const optOutMissing = push !== undefined && local === undefined
|
|
116
|
+
&& mappedTopics.some((topic) => topic.purpose === 'advertising');
|
|
117
|
+
const configurationIssue = optOutMissing
|
|
118
|
+
? 'Capacitor notifications: the catalog maps an advertising topic, whose opt-out action '
|
|
119
|
+
+ 'needs @capacitor/local-notifications; install both plugins.'
|
|
120
|
+
: undefined;
|
|
121
|
+
const assertConfigured = () => {
|
|
122
|
+
if (configurationIssue !== undefined) {
|
|
123
|
+
throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
124
|
+
message: configurationIssue,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const promptLimit = platform === 'ios' ? 1 : (input.androidPromptLimit ?? 2);
|
|
129
|
+
const app = input.app === undefined ? App : input.app;
|
|
130
|
+
const reportError = (error) => {
|
|
131
|
+
try {
|
|
132
|
+
if (input.onError === undefined) {
|
|
133
|
+
console.warn('Capacitor notifications provider failed.', error);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
input.onError(error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Reporting must not break the native listener that failed.
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// Events.
|
|
144
|
+
const listeners = new Set();
|
|
145
|
+
const buffer = [];
|
|
146
|
+
let subscribed = false;
|
|
147
|
+
// Events wait whenever no listener is attached, not only before the first:
|
|
148
|
+
// the gateway's bridge detaches when its last listener leaves, so a game
|
|
149
|
+
// that subscribes per scene would otherwise lose an open between scenes.
|
|
150
|
+
function deliver(event, targets) {
|
|
151
|
+
for (const target of targets) {
|
|
152
|
+
try {
|
|
153
|
+
target(event);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
reportError(error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
let draining = false;
|
|
161
|
+
const dispatch = (event) => {
|
|
162
|
+
if (listeners.size === 0 || draining) {
|
|
163
|
+
// A receipt is worth nothing once the scene that showed it is gone; the
|
|
164
|
+
// opens, endpoint and permission changes are kept.
|
|
165
|
+
if (subscribed && !draining && event.type === 'received') {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
buffer.push(event);
|
|
169
|
+
if (buffer.length > eventBufferLimit) {
|
|
170
|
+
// Keep the opens: a cold-start open must not be lost to later events.
|
|
171
|
+
const evicted = buffer.findIndex((buffered) => buffered.type !== 'opened');
|
|
172
|
+
buffer.splice(evicted === -1 ? 0 : evicted, 1);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
deliver(event, [...listeners]);
|
|
177
|
+
};
|
|
178
|
+
// Permission. It is read from the local plugin, which reflects the system
|
|
179
|
+
// setting on every Android version, and asked through the push plugin,
|
|
180
|
+
// which also registers with APNs on iOS.
|
|
181
|
+
let checkPermission;
|
|
182
|
+
if (local !== undefined) {
|
|
183
|
+
checkPermission = async () => (await local.checkPermissions()).display;
|
|
184
|
+
}
|
|
185
|
+
else if (push !== undefined) {
|
|
186
|
+
// Without the local plugin, Android 12 and lower read granted whatever
|
|
187
|
+
// the system setting is: the push plugin cannot tell.
|
|
188
|
+
checkPermission = async () => (await push.checkPermissions()).receive;
|
|
189
|
+
}
|
|
190
|
+
let askPermission;
|
|
191
|
+
if (push !== undefined) {
|
|
192
|
+
askPermission = async () => (await push.requestPermissions()).receive;
|
|
193
|
+
}
|
|
194
|
+
else if (local !== undefined) {
|
|
195
|
+
askPermission = async () => (await local.requestPermissions()).display;
|
|
196
|
+
}
|
|
197
|
+
let tokenFailureReported = false;
|
|
198
|
+
let lastObserved;
|
|
199
|
+
/**
|
|
200
|
+
* The OS dialog in flight, whose answer settles `lastObserved`; a request
|
|
201
|
+
* made while it is up shares it, so a double tap never asks twice.
|
|
202
|
+
*/
|
|
203
|
+
let requesting;
|
|
204
|
+
/** The answer of the last dialog that settled. */
|
|
205
|
+
let lastAnswer;
|
|
206
|
+
/** Whether a dialog that started after `generation` was taken has answered since. */
|
|
207
|
+
const answeredSince = (generation) => lastAnswer !== undefined && lastAnswer.request > generation;
|
|
208
|
+
/** Counts requests, so a read that overlapped one cannot settle `lastObserved` after it. */
|
|
209
|
+
let requests = 0;
|
|
210
|
+
const readAppState = async () => {
|
|
211
|
+
if (checkPermission === undefined) {
|
|
212
|
+
return 'unsupported';
|
|
213
|
+
}
|
|
214
|
+
const generation = requests;
|
|
215
|
+
const duringDialog = requesting !== undefined;
|
|
216
|
+
const state = await callSdk(checkPermission);
|
|
217
|
+
const mapped = toPermissionState(state);
|
|
218
|
+
// Only the request's own answer settles a request, so a read that began
|
|
219
|
+
// before or during the dialog cannot overwrite that answer, even when it
|
|
220
|
+
// finishes after the request did.
|
|
221
|
+
if (!duringDialog && requesting === undefined && !answeredSince(generation)) {
|
|
222
|
+
lastObserved = mapped;
|
|
223
|
+
}
|
|
224
|
+
return mapped;
|
|
225
|
+
};
|
|
226
|
+
const snapshotOf = (requested, state) => ({
|
|
227
|
+
app: state,
|
|
228
|
+
topics: Object.fromEntries(requested.map((topic) => [topic, mappedTopic(topic) === undefined ? 'unsupported' : state])),
|
|
229
|
+
});
|
|
230
|
+
// Settings round trips and permission changes made outside the game.
|
|
231
|
+
const appStateWaiters = new Set();
|
|
232
|
+
// One check at a time, each against the state the one before it read, so
|
|
233
|
+
// two quick returns announce one change once.
|
|
234
|
+
let resumeCheck = Promise.resolve();
|
|
235
|
+
const onAppState = (active) => {
|
|
236
|
+
for (const waiter of [...appStateWaiters]) {
|
|
237
|
+
waiter(active);
|
|
238
|
+
}
|
|
239
|
+
if (!active || unsupported) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// A push-only provider has no local reminder to retry the setup from.
|
|
243
|
+
ensureChannels().catch(reportSetupFailure);
|
|
244
|
+
ensureActionTypes().catch(reportSetupFailure);
|
|
245
|
+
resumeCheck = resumeCheck.then(async () => {
|
|
246
|
+
const before = lastObserved;
|
|
247
|
+
const generation = requests;
|
|
248
|
+
// A request's own dialog also takes the app out of the foreground (the
|
|
249
|
+
// iOS alert, the Android permission activity); the request reports that
|
|
250
|
+
// answer itself.
|
|
251
|
+
if (before === undefined || requesting !== undefined) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const state = await readAppState();
|
|
255
|
+
// A request that began, or even ended, during the read reports its own
|
|
256
|
+
// answer.
|
|
257
|
+
if (state !== before && requesting === undefined && !answeredSince(generation)) {
|
|
258
|
+
// Changed in system settings: no plugin reports it as an event.
|
|
259
|
+
dispatch({
|
|
260
|
+
type: 'permission-changed',
|
|
261
|
+
// Every declared topic; `snapshotOf` reads the unmapped ones `unsupported`.
|
|
262
|
+
snapshot: snapshotOf(topics.map((topic) => topic.id), state),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}).catch(reportError);
|
|
266
|
+
};
|
|
267
|
+
const registrationWith = (value, permission) => {
|
|
268
|
+
const timeZone = (input.timeZone ?? defaultTimeZone)();
|
|
269
|
+
const locale = (input.locale ?? defaultLocale)();
|
|
270
|
+
return {
|
|
271
|
+
provider: providerId,
|
|
272
|
+
// FCM tokens only: `fcm-installation` addresses are reserved, because
|
|
273
|
+
// FCM HTTP v1 cannot send to a Firebase installation id and the
|
|
274
|
+
// game-services sender refuses them.
|
|
275
|
+
address: { kind: 'fcm-token', value, permission },
|
|
276
|
+
...(locale === undefined || locale === '' ? {} : { locale }),
|
|
277
|
+
...(timeZone === undefined || timeZone === '' ? {} : { timeZone }),
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
const registrationOf = async (value) => registrationWith(value, await readAppState());
|
|
281
|
+
/** The declared topic a push names; the payload is untrusted. */
|
|
282
|
+
const declaredTopicOf = (data) => {
|
|
283
|
+
return mappedTopic(stringOf(data[capacitorNotificationDataKeys.topic]));
|
|
284
|
+
};
|
|
285
|
+
const openedFromPush = (actionId, notification) => {
|
|
286
|
+
const data = isRecord(notification?.data) ? notification.data : {};
|
|
287
|
+
const declared = declaredTopicOf(data);
|
|
288
|
+
const topicId = declared?.id;
|
|
289
|
+
// The opt-out action opens the topic's declared opt-out screen, never a
|
|
290
|
+
// route from the payload.
|
|
291
|
+
let route;
|
|
292
|
+
if (actionId !== notificationAdvertisingOptOutActionId) {
|
|
293
|
+
route = safeRoute(data[capacitorNotificationDataKeys.route]);
|
|
294
|
+
}
|
|
295
|
+
else if (declared?.purpose === 'advertising') {
|
|
296
|
+
route = declared.optOutRoute;
|
|
297
|
+
}
|
|
298
|
+
const extra = {};
|
|
299
|
+
for (const [key, value] of Object.entries(data)) {
|
|
300
|
+
if (typeof value === 'string' && !reservedDataKey.test(key)) {
|
|
301
|
+
extra[key] = value;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
dispatch({
|
|
305
|
+
type: 'opened',
|
|
306
|
+
channel: 'remote',
|
|
307
|
+
source: subscribed ? 'warm' : 'cold',
|
|
308
|
+
...(topicId === undefined ? {} : { topic: topicId }),
|
|
309
|
+
...(route === undefined ? {} : { route }),
|
|
310
|
+
...(Object.keys(extra).length === 0 ? {} : { data: extra }),
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
const openedFromLocal = (actionId, notification) => {
|
|
314
|
+
const entry = readLocalEntry(notification);
|
|
315
|
+
if (entry === undefined || actionId === 'dismiss') {
|
|
316
|
+
// Not one of this provider's reminders, or not an open.
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const topic = mappedTopic(entry.topic)?.id;
|
|
320
|
+
dispatch({
|
|
321
|
+
type: 'opened',
|
|
322
|
+
channel: 'local',
|
|
323
|
+
source: subscribed ? 'warm' : 'cold',
|
|
324
|
+
...(topic === undefined ? {} : { topic }),
|
|
325
|
+
...(entry.route === undefined ? {} : { route: entry.route }),
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
// Native listeners: synchronously, before any await, and never removed,
|
|
329
|
+
// because the provider lives as long as the process.
|
|
330
|
+
const listen = (register) => {
|
|
331
|
+
try {
|
|
332
|
+
register().catch(reportError);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
reportError(error);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
if (push !== undefined) {
|
|
339
|
+
listen(() => push.addListener('tokenReceived', (event) => {
|
|
340
|
+
// A configuration-required provider announces no address, as it
|
|
341
|
+
// registers none: the backend must not reach this device yet.
|
|
342
|
+
if (configurationIssue !== undefined || typeof event.token !== 'string'
|
|
343
|
+
|| event.token === '') {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const { token } = event;
|
|
347
|
+
tokenFailureReported = false;
|
|
348
|
+
// A rotated token goes out at once, with the permission this provider
|
|
349
|
+
// last read: a permission changes only in system settings, and the
|
|
350
|
+
// return from there reads it again. During a request (iOS issues the
|
|
351
|
+
// first token right after the grant) it waits for that answer, and
|
|
352
|
+
// before any read it reads first.
|
|
353
|
+
if (lastObserved !== undefined && requesting === undefined) {
|
|
354
|
+
dispatch({ type: 'endpoint-changed', registration: registrationWith(token, lastObserved) });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
void (requesting ?? Promise.resolve())
|
|
358
|
+
.catch(() => undefined)
|
|
359
|
+
.then(() => (lastObserved === undefined
|
|
360
|
+
? registrationOf(token)
|
|
361
|
+
: registrationWith(token, lastObserved)))
|
|
362
|
+
.then((registration) => dispatch({ type: 'endpoint-changed', registration }), reportError);
|
|
363
|
+
}));
|
|
364
|
+
listen(() => push.addListener('notificationReceived', (event) => {
|
|
365
|
+
const data = isRecord(event?.notification?.data) ? event.notification.data : {};
|
|
366
|
+
const topic = declaredTopicOf(data)?.id;
|
|
367
|
+
dispatch({ type: 'received', channel: 'remote', ...(topic === undefined ? {} : { topic }) });
|
|
368
|
+
}));
|
|
369
|
+
listen(() => push.addListener('notificationActionPerformed', (event) => {
|
|
370
|
+
openedFromPush(actionIdOf(event), event?.notification);
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
if (local !== undefined) {
|
|
374
|
+
listen(() => local.addListener('localNotificationReceived', (notification) => {
|
|
375
|
+
const entry = readLocalEntry(notification);
|
|
376
|
+
if (entry !== undefined) {
|
|
377
|
+
const topic = mappedTopic(entry.topic)?.id;
|
|
378
|
+
dispatch({
|
|
379
|
+
type: 'received',
|
|
380
|
+
channel: 'local',
|
|
381
|
+
...(topic === undefined ? {} : { topic }),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}));
|
|
385
|
+
listen(() => local.addListener('localNotificationActionPerformed', (event) => {
|
|
386
|
+
openedFromLocal(actionIdOf(event), event?.notification);
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
if (app !== null && !unsupported) {
|
|
390
|
+
listen(() => app.addListener('appStateChange', (state) => onAppState(state.isActive)));
|
|
391
|
+
}
|
|
392
|
+
// Native setup: the Android channels and the opt-out action type. A remote
|
|
393
|
+
// push needs its channel before it arrives, so both start now. Each is kept
|
|
394
|
+
// only once it succeeded. A failure is tried again on the next local
|
|
395
|
+
// reminder, which needs the channels and fails until they exist, and on
|
|
396
|
+
// each return to the foreground; it is reported once until a try succeeds.
|
|
397
|
+
let setupFailureReported = false;
|
|
398
|
+
const reportSetupFailure = (error) => {
|
|
399
|
+
if (!setupFailureReported) {
|
|
400
|
+
setupFailureReported = true;
|
|
401
|
+
reportError(error);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
let channelsReady;
|
|
405
|
+
const ensureChannels = () => {
|
|
406
|
+
channelsReady ??= (async () => {
|
|
407
|
+
if (platform !== 'android') {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const createChannel = local === undefined
|
|
411
|
+
? push?.createChannel?.bind(push)
|
|
412
|
+
: local.createChannel.bind(local);
|
|
413
|
+
if (createChannel === undefined) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
for (const channel of channels) {
|
|
417
|
+
await createChannel(channel);
|
|
418
|
+
}
|
|
419
|
+
setupFailureReported = false;
|
|
420
|
+
})().catch((error) => {
|
|
421
|
+
channelsReady = undefined;
|
|
422
|
+
throw error;
|
|
423
|
+
});
|
|
424
|
+
return channelsReady;
|
|
425
|
+
};
|
|
426
|
+
let actionTypesReady;
|
|
427
|
+
// The call sets every iOS category at once, so a catalog without
|
|
428
|
+
// advertising leaves the shell's categories alone.
|
|
429
|
+
const needsOptOut = mappedTopics.some((topic) => topic.purpose === 'advertising');
|
|
430
|
+
const ensureActionTypes = () => {
|
|
431
|
+
if (!needsOptOut) {
|
|
432
|
+
return Promise.resolve();
|
|
433
|
+
}
|
|
434
|
+
actionTypesReady ??= Promise.resolve()
|
|
435
|
+
.then(async () => {
|
|
436
|
+
if (local === undefined) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
await local.registerActionTypes({
|
|
440
|
+
types: [{
|
|
441
|
+
id: capacitorAdvertisingActionTypeId,
|
|
442
|
+
actions: [{
|
|
443
|
+
id: notificationAdvertisingOptOutActionId,
|
|
444
|
+
title: input.advertisingOptOutTitle ?? '수신거부',
|
|
445
|
+
foreground: true,
|
|
446
|
+
}],
|
|
447
|
+
}],
|
|
448
|
+
});
|
|
449
|
+
setupFailureReported = false;
|
|
450
|
+
})
|
|
451
|
+
.catch((error) => {
|
|
452
|
+
actionTypesReady = undefined;
|
|
453
|
+
throw error;
|
|
454
|
+
});
|
|
455
|
+
return actionTypesReady;
|
|
456
|
+
};
|
|
457
|
+
if (!unsupported) {
|
|
458
|
+
ensureChannels().catch(reportSetupFailure);
|
|
459
|
+
ensureActionTypes().catch(reportSetupFailure);
|
|
460
|
+
}
|
|
461
|
+
// Local reminder operations run one at a time: each reads the pending list
|
|
462
|
+
// and decides from it, so two at once could both take the last free slot.
|
|
463
|
+
let localQueue = Promise.resolve();
|
|
464
|
+
const serialized = (operation) => {
|
|
465
|
+
const run = localQueue.then(operation, operation);
|
|
466
|
+
localQueue = run.catch(() => undefined);
|
|
467
|
+
return run;
|
|
468
|
+
};
|
|
469
|
+
const availabilityOf = () => {
|
|
470
|
+
if (unsupported) {
|
|
471
|
+
return 'unsupported';
|
|
472
|
+
}
|
|
473
|
+
return configurationIssue === undefined ? 'available' : 'configuration-required';
|
|
474
|
+
};
|
|
475
|
+
const capabilities = () => ({
|
|
476
|
+
availability: availabilityOf(),
|
|
477
|
+
permission: {
|
|
478
|
+
scope: 'app',
|
|
479
|
+
prompt: 'os',
|
|
480
|
+
readable: true,
|
|
481
|
+
serverReadable: false,
|
|
482
|
+
allowance: 'persistent',
|
|
483
|
+
userGesture: false,
|
|
484
|
+
...(unsupported ? {} : { promptLimit }),
|
|
485
|
+
settings: !unsupported && input.openSettings !== undefined,
|
|
486
|
+
},
|
|
487
|
+
remote: push === undefined
|
|
488
|
+
? { supported: false, purposes: [], addressing: 'none', content: 'free-form' }
|
|
489
|
+
: {
|
|
490
|
+
supported: true,
|
|
491
|
+
purposes: ['service', 'requested', 'advertising'],
|
|
492
|
+
addressing: 'device',
|
|
493
|
+
content: 'free-form',
|
|
494
|
+
},
|
|
495
|
+
local: local === undefined
|
|
496
|
+
? { supported: false }
|
|
497
|
+
: {
|
|
498
|
+
supported: true,
|
|
499
|
+
...(platform === 'ios' ? { maxPending: capacitorIosMaxPendingLocalNotifications } : {}),
|
|
500
|
+
},
|
|
501
|
+
open: unsupported ? 'none' : 'payload',
|
|
502
|
+
});
|
|
503
|
+
const readPending = async (sdk) => {
|
|
504
|
+
const result = await callSdk(() => sdk.getPending());
|
|
505
|
+
const all = Array.isArray(result?.notifications) ? result.notifications : [];
|
|
506
|
+
const ours = [];
|
|
507
|
+
for (const notification of all) {
|
|
508
|
+
const entry = readLocalEntry(notification);
|
|
509
|
+
if (entry !== undefined) {
|
|
510
|
+
ours.push({
|
|
511
|
+
...entry,
|
|
512
|
+
id: notification.id,
|
|
513
|
+
...(typeof notification.title === 'string' ? { title: notification.title } : {}),
|
|
514
|
+
...(typeof notification.body === 'string' ? { body: notification.body } : {}),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return { all, ours: sortEntries(ours) };
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* Schedules a validated, declared, non-advertising reminder once permission
|
|
522
|
+
* and channels are in place; runs inside `serialized`.
|
|
523
|
+
*/
|
|
524
|
+
const scheduleChecked = async (sdk, checked, declared) => {
|
|
525
|
+
// Never let the plugin prompt: its schedule asks by itself.
|
|
526
|
+
if (await readAppState() !== 'granted') {
|
|
527
|
+
return { status: 'permission-required' };
|
|
528
|
+
}
|
|
529
|
+
await callSdk(ensureChannels);
|
|
530
|
+
ensureActionTypes().catch(reportSetupFailure);
|
|
531
|
+
const fireAt = normalizeNotificationTimestamp(checked.fireAt);
|
|
532
|
+
const pending = await readPending(sdk);
|
|
533
|
+
const existing = pending.ours.find((entry) => entry.key === checked.key);
|
|
534
|
+
const maxPending = platform === 'ios' ? capacitorIosMaxPendingLocalNotifications : undefined;
|
|
535
|
+
let evicted;
|
|
536
|
+
if (existing === undefined && maxPending !== undefined
|
|
537
|
+
&& pending.all.length >= maxPending) {
|
|
538
|
+
// Keep the nearest: the farthest of this provider's reminders
|
|
539
|
+
// gives way, or the new one is refused.
|
|
540
|
+
const farthest = pending.ours.at(-1);
|
|
541
|
+
if (farthest === undefined || Date.parse(fireAt) >= Date.parse(farthest.fireAt)) {
|
|
542
|
+
return { status: 'capacity-exceeded' };
|
|
543
|
+
}
|
|
544
|
+
await callSdk(() => sdk.cancel({ notifications: [{ id: farthest.id }] }));
|
|
545
|
+
evicted = farthest;
|
|
546
|
+
}
|
|
547
|
+
const id = existing?.id ?? freeId(checked.key, pending.all);
|
|
548
|
+
const route = checked.route ?? declared.route;
|
|
549
|
+
try {
|
|
550
|
+
await callSdk(() => sdk.schedule({
|
|
551
|
+
notifications: [schemaOf({ ...checked, id, fireAt, route })],
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
// The new reminder failed after the farthest gave way: put that one back.
|
|
556
|
+
if (evicted !== undefined) {
|
|
557
|
+
await restore(sdk, evicted);
|
|
558
|
+
}
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
return { status: existing === undefined ? 'scheduled' : 'replaced' };
|
|
562
|
+
};
|
|
563
|
+
/** One local notification as this provider writes it, for a new or a restored reminder. */
|
|
564
|
+
const schemaOf = (input) => {
|
|
565
|
+
const declared = mappedTopic(input.topic);
|
|
566
|
+
return {
|
|
567
|
+
id: input.id,
|
|
568
|
+
title: input.title,
|
|
569
|
+
body: input.body,
|
|
570
|
+
// Inexact, but allowed to fire in Doze: without it Android posts the
|
|
571
|
+
// reminder only when the device next wakes for another reason.
|
|
572
|
+
schedule: { at: new Date(input.fireAt), allowWhileIdle: true },
|
|
573
|
+
extra: {
|
|
574
|
+
[extraKeys.key]: input.key,
|
|
575
|
+
[extraKeys.topic]: input.topic,
|
|
576
|
+
[extraKeys.fireAt]: input.fireAt,
|
|
577
|
+
...(input.route === undefined ? {} : { [extraKeys.route]: input.route }),
|
|
578
|
+
},
|
|
579
|
+
...(platform === 'android' && declared !== undefined
|
|
580
|
+
? { channelId: capacitorNotificationChannelId(declared) }
|
|
581
|
+
: {}),
|
|
582
|
+
isExactNotification: false,
|
|
583
|
+
};
|
|
584
|
+
};
|
|
585
|
+
/** Puts an evicted reminder back as `getPending` reported it; reports when it cannot. */
|
|
586
|
+
const restore = async (sdk, entry) => {
|
|
587
|
+
const { title, body } = entry;
|
|
588
|
+
if (title === undefined || body === undefined) {
|
|
589
|
+
reportError(new Error(`Capacitor notifications: could not restore the reminder ${entry.key}.`));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
await callSdk(() => sdk.schedule({
|
|
594
|
+
notifications: [schemaOf({ ...entry, title, body })],
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
reportError(error);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
const provider = {
|
|
602
|
+
provider: providerId,
|
|
603
|
+
topics,
|
|
604
|
+
async getCapabilities() {
|
|
605
|
+
return capabilities();
|
|
606
|
+
},
|
|
607
|
+
async getPermission(request) {
|
|
608
|
+
const requested = topicsOf(request);
|
|
609
|
+
assertConfigured();
|
|
610
|
+
return snapshotOf(requested, await readAppState());
|
|
611
|
+
},
|
|
612
|
+
async requestPermission(request) {
|
|
613
|
+
const requested = topicsOf(request);
|
|
614
|
+
assertConfigured();
|
|
615
|
+
const outcomes = {};
|
|
616
|
+
if (askPermission === undefined) {
|
|
617
|
+
for (const topic of requested) {
|
|
618
|
+
outcomes[topic] = 'unavailable';
|
|
619
|
+
}
|
|
620
|
+
return { snapshot: snapshotOf(requested, 'unsupported'), outcomes };
|
|
621
|
+
}
|
|
622
|
+
const askable = requested.filter((topic) => mappedTopic(topic) !== undefined);
|
|
623
|
+
for (const topic of requested) {
|
|
624
|
+
outcomes[topic] = 'unavailable';
|
|
625
|
+
}
|
|
626
|
+
if (askable.length === 0) {
|
|
627
|
+
return { snapshot: snapshotOf(requested, await readAppState()), outcomes };
|
|
628
|
+
}
|
|
629
|
+
let answer = requesting;
|
|
630
|
+
if (answer === undefined) {
|
|
631
|
+
const generation = requests;
|
|
632
|
+
const before = await readAppState();
|
|
633
|
+
// Another request may have opened the dialog during that read, or
|
|
634
|
+
// opened and closed it: then this call shares that answer instead
|
|
635
|
+
// of asking again from a state read before it.
|
|
636
|
+
answer = requesting
|
|
637
|
+
// Only a dialog that started after the read began, and answered.
|
|
638
|
+
?? (lastAnswer !== undefined && answeredSince(generation)
|
|
639
|
+
? Promise.resolve(lastAnswer)
|
|
640
|
+
: undefined);
|
|
641
|
+
if (answer === undefined && before === 'blocked') {
|
|
642
|
+
// Only settings can change it; the dialog would not show.
|
|
643
|
+
for (const topic of askable) {
|
|
644
|
+
outcomes[topic] = 'blocked';
|
|
645
|
+
}
|
|
646
|
+
return { snapshot: snapshotOf(requested, before), outcomes };
|
|
647
|
+
}
|
|
648
|
+
if (answer === undefined) {
|
|
649
|
+
// The push plugin also registers with APNs on iOS. Its answer is
|
|
650
|
+
// not read: on Android 12 and lower it is granted whatever the
|
|
651
|
+
// setting is, so the read after it decides (through the local
|
|
652
|
+
// plugin when present).
|
|
653
|
+
requests += 1;
|
|
654
|
+
const request = requests;
|
|
655
|
+
// Started in a microtask, so `requesting` is set before the dialog
|
|
656
|
+
// opens and every read from then on sees it.
|
|
657
|
+
const asked = Promise.resolve().then(async () => {
|
|
658
|
+
await callSdk(askPermission);
|
|
659
|
+
const after = await readAppState();
|
|
660
|
+
lastObserved = after;
|
|
661
|
+
lastAnswer = { request, before, after };
|
|
662
|
+
return lastAnswer;
|
|
663
|
+
});
|
|
664
|
+
requesting = asked;
|
|
665
|
+
const clear = () => {
|
|
666
|
+
if (requesting === asked) {
|
|
667
|
+
requesting = undefined;
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
asked.then(clear, clear);
|
|
671
|
+
answer = asked;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const { before, after } = await answer;
|
|
675
|
+
let outcome;
|
|
676
|
+
if (after === 'granted') {
|
|
677
|
+
outcome = before === 'granted' ? 'already-granted' : 'granted';
|
|
678
|
+
}
|
|
679
|
+
else if (after === before) {
|
|
680
|
+
// A dialog closed without an answer leaves the state as it was.
|
|
681
|
+
outcome = 'dismissed';
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
outcome = 'denied';
|
|
685
|
+
}
|
|
686
|
+
for (const topic of askable) {
|
|
687
|
+
outcomes[topic] = outcome;
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
snapshot: snapshotOf(requested, after),
|
|
691
|
+
outcomes,
|
|
692
|
+
};
|
|
693
|
+
},
|
|
694
|
+
...(push === undefined
|
|
695
|
+
? {}
|
|
696
|
+
: {
|
|
697
|
+
async register() {
|
|
698
|
+
assertConfigured();
|
|
699
|
+
let value;
|
|
700
|
+
try {
|
|
701
|
+
value = (await push.getToken()).token;
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
if (error instanceof NotificationError || isConfigurationFailure(error)) {
|
|
705
|
+
throw toNotificationError(error);
|
|
706
|
+
}
|
|
707
|
+
// No address yet (iOS before APNs registration, offline):
|
|
708
|
+
// `tokenReceived` announces it as `endpoint-changed`. Reported
|
|
709
|
+
// once until a token arrives, because a Firebase set up wrongly
|
|
710
|
+
// fails the same way.
|
|
711
|
+
if (!tokenFailureReported) {
|
|
712
|
+
tokenFailureReported = true;
|
|
713
|
+
reportError(error);
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
if (typeof value !== 'string' || value === '') {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
tokenFailureReported = false;
|
|
721
|
+
return registrationOf(value);
|
|
722
|
+
},
|
|
723
|
+
}),
|
|
724
|
+
...(local === undefined
|
|
725
|
+
? {}
|
|
726
|
+
: {
|
|
727
|
+
async scheduleLocal(request) {
|
|
728
|
+
let checked;
|
|
729
|
+
try {
|
|
730
|
+
checked = assertLocalNotificationRequest(request);
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
734
|
+
message: error instanceof Error ? error.message : 'Invalid local reminder.',
|
|
735
|
+
cause: error,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
const declared = mappedTopic(checked.topic);
|
|
739
|
+
if (declared === undefined || declared.purpose === 'advertising') {
|
|
740
|
+
// Outside the resolved catalog it has no channel, as a request
|
|
741
|
+
// for it answers `unavailable`; and a device-scheduled
|
|
742
|
+
// advertisement could not be withdrawn, labelled, or held for
|
|
743
|
+
// the night window (RFC "Topic catalog").
|
|
744
|
+
return { status: 'unsupported' };
|
|
745
|
+
}
|
|
746
|
+
// The whole operation holds the queue, so concurrent calls apply
|
|
747
|
+
// in the order they were made.
|
|
748
|
+
return serialized(() => scheduleChecked(local, checked, declared));
|
|
749
|
+
},
|
|
750
|
+
cancelLocal(request) {
|
|
751
|
+
if (!Array.isArray(request?.keys)) {
|
|
752
|
+
return Promise.reject(new NotificationError('NOTIFICATIONS_INVALID_INPUT'));
|
|
753
|
+
}
|
|
754
|
+
const keys = new Set(request.keys);
|
|
755
|
+
return serialized(async () => {
|
|
756
|
+
const ids = (await readPending(local)).ours
|
|
757
|
+
.filter((entry) => keys.has(entry.key))
|
|
758
|
+
.map((entry) => ({ id: entry.id }));
|
|
759
|
+
if (ids.length > 0) {
|
|
760
|
+
await callSdk(() => local.cancel({ notifications: ids }));
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
},
|
|
764
|
+
listLocal() {
|
|
765
|
+
return serialized(async () => (await readPending(local)).ours
|
|
766
|
+
.map(({ key, topic, fireAt }) => ({ key, topic, fireAt })));
|
|
767
|
+
},
|
|
768
|
+
}),
|
|
769
|
+
async openSettings() {
|
|
770
|
+
const open = input.openSettings;
|
|
771
|
+
if (unsupported || open === undefined) {
|
|
772
|
+
return 'unavailable';
|
|
773
|
+
}
|
|
774
|
+
// Resolve once the player is back, so the game stays paused while the
|
|
775
|
+
// settings screen is up: on Android the native call returns as soon as
|
|
776
|
+
// the screen launches, before the app reports the background.
|
|
777
|
+
let finish = () => undefined;
|
|
778
|
+
/** Whether the wait ended by a timer rather than the player's return. */
|
|
779
|
+
let timedOut = false;
|
|
780
|
+
const returned = new Promise((resolve) => {
|
|
781
|
+
let left = false;
|
|
782
|
+
const done = () => {
|
|
783
|
+
appStateWaiters.delete(waiter);
|
|
784
|
+
clearTimeout(leaveTimer);
|
|
785
|
+
clearTimeout(returnTimer);
|
|
786
|
+
resolve();
|
|
787
|
+
};
|
|
788
|
+
const expire = () => {
|
|
789
|
+
timedOut = true;
|
|
790
|
+
done();
|
|
791
|
+
};
|
|
792
|
+
const waiter = (active) => {
|
|
793
|
+
if (!active) {
|
|
794
|
+
left = true;
|
|
795
|
+
}
|
|
796
|
+
else if (left) {
|
|
797
|
+
done();
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
appStateWaiters.add(waiter);
|
|
801
|
+
// A screen that never took the app to the background does not hold the game.
|
|
802
|
+
const leaveTimer = setTimeout(() => {
|
|
803
|
+
if (!left) {
|
|
804
|
+
expire();
|
|
805
|
+
}
|
|
806
|
+
}, input.settingsLeaveTimeoutMs ?? capacitorSettingsLeaveTimeoutMs);
|
|
807
|
+
const returnTimer = setTimeout(expire, input.settingsReturnTimeoutMs
|
|
808
|
+
?? capacitorSettingsReturnTimeoutMs);
|
|
809
|
+
finish = done;
|
|
810
|
+
});
|
|
811
|
+
// The opener is raced with the wait: one that never settles must not
|
|
812
|
+
// hold the game past the timers, and settling after they ended changes
|
|
813
|
+
// nothing (a late rejection is only reported).
|
|
814
|
+
const opening = Promise.resolve()
|
|
815
|
+
.then(() => open())
|
|
816
|
+
.then(() => 'opened', (error) => {
|
|
817
|
+
reportError(error);
|
|
818
|
+
return 'failed';
|
|
819
|
+
});
|
|
820
|
+
const outcome = await Promise.race([
|
|
821
|
+
opening,
|
|
822
|
+
returned.then(() => 'waited'),
|
|
823
|
+
]);
|
|
824
|
+
if (outcome === 'failed') {
|
|
825
|
+
finish();
|
|
826
|
+
return 'unavailable';
|
|
827
|
+
}
|
|
828
|
+
if (outcome === 'waited') {
|
|
829
|
+
// The wait ended before the opener settled: answered as a missed
|
|
830
|
+
// return is. A timer ending it is a stall, reported once; the player
|
|
831
|
+
// coming back first is a normal round trip (some iOS openers settle
|
|
832
|
+
// only after the settings screen closes).
|
|
833
|
+
if (timedOut) {
|
|
834
|
+
reportError(new Error('Capacitor notifications: openSettings had not settled when the settings wait timed out.'));
|
|
835
|
+
}
|
|
836
|
+
return 'opened';
|
|
837
|
+
}
|
|
838
|
+
await returned;
|
|
839
|
+
return 'opened';
|
|
840
|
+
},
|
|
841
|
+
subscribe(subscriber) {
|
|
842
|
+
// One entry per call, so the same function subscribed twice is two
|
|
843
|
+
// subscriptions, and only the handle this call returns removes this one.
|
|
844
|
+
const listener = (event) => subscriber(event);
|
|
845
|
+
const first = listeners.size === 0;
|
|
846
|
+
listeners.add(listener);
|
|
847
|
+
// `source` reads `cold` only for opens before the first subscriber ever.
|
|
848
|
+
subscribed = true;
|
|
849
|
+
if (first && !draining) {
|
|
850
|
+
// Every buffered event reaches the next subscriber exactly once, in
|
|
851
|
+
// order: an event dispatched while the buffer drains queues behind it
|
|
852
|
+
// and then reaches every listener attached by then, as a live one does.
|
|
853
|
+
draining = true;
|
|
854
|
+
// The backlog is taken whole, so an eviction while it drains touches
|
|
855
|
+
// only events dispatched meanwhile. This subscription cannot leave
|
|
856
|
+
// during the drain: it runs inside this `subscribe` call, before the
|
|
857
|
+
// call returns the only handle that removes it.
|
|
858
|
+
const backlog = buffer.splice(0);
|
|
859
|
+
try {
|
|
860
|
+
for (const event of backlog) {
|
|
861
|
+
deliver(event, [listener]);
|
|
862
|
+
}
|
|
863
|
+
for (let event = buffer.shift(); event !== undefined; event = buffer.shift()) {
|
|
864
|
+
deliver(event, [...listeners]);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
draining = false;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return () => {
|
|
872
|
+
listeners.delete(listener);
|
|
873
|
+
};
|
|
874
|
+
},
|
|
875
|
+
};
|
|
876
|
+
return provider;
|
|
877
|
+
}
|
|
878
|
+
function collectChannels(topics, settings) {
|
|
879
|
+
const purposes = new Map();
|
|
880
|
+
for (const topic of topics) {
|
|
881
|
+
const id = capacitorNotificationChannelId(topic);
|
|
882
|
+
const seen = purposes.get(id);
|
|
883
|
+
if (seen !== undefined && (seen === 'advertising') !== (topic.purpose === 'advertising')) {
|
|
884
|
+
// RFC "Provider profiles", Capacitor: advertising has channels of its
|
|
885
|
+
// own, so a player can turn it off without losing service messages.
|
|
886
|
+
throw new Error(`Capacitor notifications: channel ${id} mixes advertising and other topics (${topic.id}).`);
|
|
887
|
+
}
|
|
888
|
+
purposes.set(id, seen ?? topic.purpose);
|
|
889
|
+
}
|
|
890
|
+
return [...purposes].map(([id, purpose]) => {
|
|
891
|
+
const channel = Object.prototype.hasOwnProperty.call(settings, id) ? settings[id] : undefined;
|
|
892
|
+
const fallback = defaultChannels[purpose];
|
|
893
|
+
return {
|
|
894
|
+
id,
|
|
895
|
+
name: channel?.name ?? fallback.name,
|
|
896
|
+
...(channel?.description === undefined ? {} : { description: channel.description }),
|
|
897
|
+
importance: channel?.importance ?? 3,
|
|
898
|
+
};
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
function topicsOf(request) {
|
|
902
|
+
if (!Array.isArray(request?.topics)
|
|
903
|
+
|| !request.topics.every((topic) => typeof topic === 'string')) {
|
|
904
|
+
throw new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
905
|
+
message: 'Notification topics must be a list of topic ids.',
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return [...new Set(request.topics)];
|
|
909
|
+
}
|
|
910
|
+
function toPermissionState(state) {
|
|
911
|
+
switch (state) {
|
|
912
|
+
case 'granted':
|
|
913
|
+
return 'granted';
|
|
914
|
+
case 'prompt':
|
|
915
|
+
return 'prompt';
|
|
916
|
+
case 'prompt-with-rationale':
|
|
917
|
+
return 'prompt-with-rationale';
|
|
918
|
+
case 'denied':
|
|
919
|
+
// iOS after its one denial, Android after the second: only settings
|
|
920
|
+
// can change it.
|
|
921
|
+
return 'blocked';
|
|
922
|
+
default:
|
|
923
|
+
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
924
|
+
message: `The notification plugin reported an unknown permission state: ${String(state)}.`,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/** This provider's key, topic, and time from a local notification's `extra`, or `undefined` for another one. */
|
|
929
|
+
function readLocalEntry(notification) {
|
|
930
|
+
const extra = notification?.extra;
|
|
931
|
+
if (!isRecord(extra) || typeof notification?.id !== 'number') {
|
|
932
|
+
return undefined;
|
|
933
|
+
}
|
|
934
|
+
const key = stringOf(extra[extraKeys.key]);
|
|
935
|
+
const topic = stringOf(extra[extraKeys.topic]);
|
|
936
|
+
const fireAt = stringOf(extra[extraKeys.fireAt]);
|
|
937
|
+
if (key === undefined || topic === undefined || fireAt === undefined
|
|
938
|
+
|| Number.isNaN(Date.parse(fireAt))) {
|
|
939
|
+
return undefined;
|
|
940
|
+
}
|
|
941
|
+
const route = safeRoute(extra[extraKeys.route]);
|
|
942
|
+
return { key, topic, fireAt, ...(route === undefined ? {} : { route }) };
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* A route the game may open, by the rule its router applies
|
|
946
|
+
* (`isRootRelativeNotificationRoute`): at most 2,048 characters and already
|
|
947
|
+
* in the form `normalizeRootRelativeRoute` from `@mpgd/platform/routes`
|
|
948
|
+
* returns, which also refuses any path a client could read as another host
|
|
949
|
+
* (`hasNetworkPathPrefix`).
|
|
950
|
+
*/
|
|
951
|
+
function safeRoute(value) {
|
|
952
|
+
return typeof value === 'string' && isRootRelativeNotificationRoute(value) ? value : undefined;
|
|
953
|
+
}
|
|
954
|
+
/** The action a native event names; `tap` when it names none. */
|
|
955
|
+
function actionIdOf(event) {
|
|
956
|
+
return typeof event?.actionId === 'string' ? event.actionId : 'tap';
|
|
957
|
+
}
|
|
958
|
+
function freeId(key, pending) {
|
|
959
|
+
const taken = new Set(pending.map((entry) => entry.id));
|
|
960
|
+
let id = capacitorLocalNotificationId(key);
|
|
961
|
+
while (taken.has(id)) {
|
|
962
|
+
id = id === maxNativeId ? 1 : id + 1;
|
|
963
|
+
}
|
|
964
|
+
return id;
|
|
965
|
+
}
|
|
966
|
+
function sortEntries(entries) {
|
|
967
|
+
return [...entries].sort((left, right) => {
|
|
968
|
+
const byTime = Date.parse(left.fireAt) - Date.parse(right.fireAt);
|
|
969
|
+
if (byTime !== 0) {
|
|
970
|
+
return byTime;
|
|
971
|
+
}
|
|
972
|
+
// Code unit order, the same on every device locale.
|
|
973
|
+
if (left.key === right.key) {
|
|
974
|
+
return 0;
|
|
975
|
+
}
|
|
976
|
+
return left.key < right.key ? -1 : 1;
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
async function callSdk(call) {
|
|
980
|
+
try {
|
|
981
|
+
return await call();
|
|
982
|
+
}
|
|
983
|
+
catch (error) {
|
|
984
|
+
throw toNotificationError(error);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function toNotificationError(error) {
|
|
988
|
+
if (error instanceof NotificationError) {
|
|
989
|
+
return error;
|
|
990
|
+
}
|
|
991
|
+
const code = isRecord(error) ? stringOf(error['code']) : undefined;
|
|
992
|
+
if (isConfigurationFailure(error)) {
|
|
993
|
+
return new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
|
|
994
|
+
...(code === undefined ? {} : { hostCode: code }),
|
|
995
|
+
cause: error,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
return new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', { cause: error });
|
|
999
|
+
}
|
|
1000
|
+
/** Capacitor's answer for a plugin the native shell does not implement. */
|
|
1001
|
+
function isConfigurationFailure(error) {
|
|
1002
|
+
const code = isRecord(error) ? error['code'] : undefined;
|
|
1003
|
+
return code === 'UNIMPLEMENTED' || code === 'UNAVAILABLE';
|
|
1004
|
+
}
|
|
1005
|
+
function defaultTimeZone() {
|
|
1006
|
+
try {
|
|
1007
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
return undefined;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
function defaultLocale() {
|
|
1014
|
+
const language = globalThis
|
|
1015
|
+
.navigator?.language;
|
|
1016
|
+
return typeof language === 'string' ? language : undefined;
|
|
1017
|
+
}
|
|
1018
|
+
function isRecord(value) {
|
|
1019
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1020
|
+
}
|
|
1021
|
+
function stringOf(value) {
|
|
1022
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
1023
|
+
}
|