@mpgd/adapter-capacitor 0.10.1 → 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.
@@ -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
+ }