@mpgd/adapter-capacitor 0.11.1 → 0.12.1

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
@@ -194,6 +194,7 @@ import { resolveNotificationTopicsForTarget } from '@mpgd/catalog';
194
194
  import {
195
195
  createCapacitorNotificationServiceProvider,
196
196
  loadCapacitorNotificationSdks,
197
+ openCapacitorNotificationSettings,
197
198
  } from '@mpgd/adapter-capacitor/notifications';
198
199
 
199
200
  const sdks = await loadCapacitorNotificationSdks();
@@ -201,12 +202,16 @@ const notifications = createCapacitorNotificationServiceProvider({
201
202
  platform: 'android',
202
203
  topics: resolveNotificationTopicsForTarget(catalog, 'android'),
203
204
  ...sdks,
204
- // Optional: opens this app's notification settings (see below).
205
- openSettings,
205
+ // Opens this app's notification settings (see below).
206
+ openSettings: openCapacitorNotificationSettings,
206
207
  });
207
208
  const gateway = createCapacitorPlatformGateway({ ..., providers: [notifications] });
208
209
  ```
209
210
 
211
+ <!--
212
+ @link src/notifications-provider.ts#createCapacitorNotificationProvider Android omits advertising from remote purposes, waits for channels before register, refuses a token when channels cannot be created, ignores a dismiss, confirms an iOS schedule by content, and keeps a Doze-deferred alarm visible to cancellation.
213
+ @evidence ../../docs/specs/notifications/notifications-v2-rfc.md#profile-capacitor An Android FCM notification message cannot carry the opt-out action, and a killed-app tap is only in connectionOptions.notificationResponse.
214
+ -->
210
215
  Create it once, early in startup: it registers its native listeners when it
211
216
  is created, which is how a cold-start open reaches it.
212
217
 
@@ -239,7 +244,12 @@ is created, which is how a cold-start open reaches it.
239
244
  `NOTIFICATIONS_V2_UNAVAILABLE` before it pauses the game.
240
245
  - **Permission.** One OS permission for the app. The provider reads it through
241
246
  the local plugin, which reflects the system setting on Android 12 and lower
242
- where the push plugin answers `granted` regardless. The OS `denied` reads
247
+ where the push plugin answers `granted` regardless. On Android 13+ a granted
248
+ `POST_NOTIFICATIONS` with notifications turned off in settings
249
+ (`areNotificationsEnabled() == false`) reads `blocked`. iOS provisional and
250
+ ephemeral authorization, when a shell reports them instead of collapsing
251
+ them to `granted`, read `prompt` so a full prompt can still upgrade them.
252
+ The OS `denied` reads
243
253
  `blocked`: after one denial on iOS (`promptLimit` 1) and after the second on
244
254
  Android 13+ (`promptLimit` 2; pass `androidPromptLimit: 1` for an app that
245
255
  targets SDK 32 or lower). Only `requestPermission` shows the dialog, and a
@@ -251,6 +261,15 @@ is created, which is how a cold-start open reaches it.
251
261
  schedules locally. Otherwise it reads the permission first and answers
252
262
  `permission-required` without calling the plugin, whose own `schedule` would
253
263
  prompt on Android 13+.
264
+ iOS `schedule` resolves before `center.add` finishes, so the provider
265
+ checks `getPending` for the reminder's key, topic, `fireAt`, and route — not only
266
+ its id — and waits with a bounded backoff before it answers. An add still
267
+ missing when that wait ends is not a failure: `center.add` has no deadline,
268
+ and failing it would restore a reminder evicted to make room while the new
269
+ add may still be in flight. Android `listLocal` omits a stored row only
270
+ when `getDeliveredNotifications` shows it in the shade. A past `fireAt`
271
+ that is not delivered stays listed: Doze can defer an inexact alarm past
272
+ its time while it is still pending, and cancellation reads `listLocal`.
254
273
  - Reminders are inexact (`isExactNotification: false`; the plugin's default
255
274
  would open the exact-alarm settings screen) but may fire in Android's Doze
256
275
  mode (`allowWhileIdle: true`; Android lets such alarms fire about once per
@@ -260,15 +279,18 @@ is created, which is how a cold-start open reaches it.
260
279
  the topic's Android channel. Operations run one at a time, in call order.
261
280
  - iOS keeps at most 64 pending, so the provider keeps the nearest: at the
262
281
  ceiling it cancels the farthest of its reminders before it schedules a
263
- nearer one, and puts the farthest back when that schedule fails. Android
264
- declares no ceiling, but its alarm manager refuses an app with about 500
265
- pending alarms.
282
+ nearer one, and puts the farthest back when that schedule is rejected.
283
+ An add the plugin accepted but that is not yet pending is not a rejection,
284
+ so the farthest is not put back then. Android declares no ceiling, but its
285
+ alarm manager refuses an app with about 500 pending alarms.
266
286
  - **Channels.** Each topic posts to its mapping's `channelId`, or to
267
287
  `mpgd-service`, `mpgd-requested`, or `mpgd-advertising` by purpose
268
288
  (`capacitorNotificationChannelId`). Creating the provider throws when a
269
289
  channel would mix advertising with other topics, so a player can always turn
270
290
  advertising off in system settings. Name the channels with the `channels`
271
- option.
291
+ option. A push-only Android shell whose messaging plugin has no
292
+ `createChannel` is `configuration-required` when the catalog names a
293
+ channel: `register()` throws and a token is not announced.
272
294
  - **Topics.** Only topics this target maps count. A topic the catalog does
273
295
  not declare, or declares without a mapping for the target, reads
274
296
  `unsupported`, answers `unavailable` to a request and `unsupported` to
@@ -283,8 +305,12 @@ is created, which is how a cold-start open reaches it.
283
305
  the like), reach the game as untrusted `data`. A route is kept only when
284
306
  `isRootRelativeNotificationRoute` accepts it, and a topic only when the
285
307
  catalog declares it. Nothing is granted from an open. An open that arrives
286
- before the first subscriber is marked `cold`; Capacitor replays a cold-start
287
- tap asynchronously, so one replayed after the game subscribed reads `warm`.
308
+ before the first subscriber is marked `cold`. A killed-app tap on iOS is
309
+ only in `UIScene.ConnectionOptions.notificationResponse`. The reference
310
+ shell's `SceneDelegate` forwards it to the notification-center delegate
311
+ once the bridge has loaded plugins (`capacitorViewDidAppear`); Capacitor
312
+ 8.5's scene proxy does not. Capacitor then replays that tap to the first
313
+ native listener, so one replayed after the game subscribed reads `warm`.
288
314
  - **Advertising opt-out.** For a catalog with an advertising topic, the
289
315
  provider registers the action type `mpgd-advertising`
290
316
  (`capacitorAdvertisingActionTypeId`) with the `advertising-opt-out` action
@@ -296,17 +322,34 @@ is created, which is how a cold-start open reaches it.
296
322
  - The action opens the `optOutRoute` of the advertising topic the push names
297
323
  in its `topic` data key; without that key it opens nothing.
298
324
  - An Android FCM notification message cannot carry actions, so an Android
299
- advertising push needs the shell to render it natively with the action;
300
- the template work for that is C1's.
301
- - **Settings.** Neither plugin opens the notification settings, so the shell
302
- passes `openSettings` (for example a small native method); without it
303
- `permission.settings` is `false` and `openSettings` answers `unavailable`.
325
+ advertising push needs the shell to render it natively with the action,
326
+ which is not built yet (a follow-up after C1). Android `remote.purposes`
327
+ still lists `advertising` so the provider pairs with the standard FCM
328
+ registry, but `validateNotificationTopicCatalog` refuses a catalog that
329
+ maps an advertising topic to `android`: the push would arrive without the
330
+ opt-out action the Korean rules require (see the RFC's advertising
331
+ rules). The kit's starter maps its sample advertising topic to iOS only.
332
+ - **Settings.** Neither plugin opens the notification settings, so the
333
+ provider takes an `openSettings` opener; without one `permission.settings`
334
+ is `false` and `openSettings` answers `unavailable`.
335
+ `openCapacitorNotificationSettings` is the kit's opener: it calls
336
+ `openNotificationSettings` of `@mpgd/capacitor-game-services` (Android 8+
337
+ `Settings.ACTION_APP_NOTIFICATION_SETTINGS`, iOS 16+
338
+ `UIApplication.openNotificationSettingsURLString`, and each app's settings
339
+ page on older versions) and throws when the screen did not open. A shell
340
+ built with a plugin older than the method rejects the call, and the
341
+ provider then answers `unavailable` and reports the error.
304
342
  The provider resolves `openSettings` only once the app is back in the
305
343
  foreground, after at most two minutes (`settingsReturnTimeoutMs`), or after
306
344
  five seconds when the app never left.
307
345
  - **Endpoints.** `register()` answers the FCM token with the device
308
346
  permission, locale, and time zone, or `null` while Firebase cannot issue one
309
- yet; a token Firebase issues later arrives as `endpoint-changed`. It
347
+ yet; a token Firebase issues later arrives as `endpoint-changed`. On
348
+ Android it waits until the topic channels exist, and does not return a
349
+ token if creating them failed: FCM sets `android.notification.channel_id`,
350
+ so a push that arrives before the channel exists is dropped. A rotated
351
+ token is announced at once with the last permission read, then replaced if
352
+ a fresh read differs (a settings change has no event). It
310
353
  registers FCM tokens only: `fcm-installation` addresses are reserved,
311
354
  because FCM HTTP v1 cannot send to a Firebase installation id and the
312
355
  game-services FCM sender refuses them.
@@ -71,6 +71,18 @@ export interface CreateCapacitorNotificationProviderInput {
71
71
  readonly timeZone?: () => string | undefined;
72
72
  /** The device locale for registrations; defaults to `navigator.language`. */
73
73
  readonly locale?: () => string | undefined;
74
+ /**
75
+ * Scenario clock for whether a reminder is already due. A future reminder
76
+ * must be pending before `scheduleLocal` answers; an already-due one posts
77
+ * at once. Defaults to `Date.now`. The iOS confirm wait uses the wall
78
+ * clock instead, because conformance freezes this one.
79
+ */
80
+ readonly now?: () => number;
81
+ /**
82
+ * How long `scheduleLocal` waits for an iOS `center.add` that is still in
83
+ * flight before it treats the add as unconfirmed. Defaults to 2 seconds.
84
+ */
85
+ readonly scheduleConfirmBudgetMs?: number;
74
86
  readonly settingsLeaveTimeoutMs?: number;
75
87
  readonly settingsReturnTimeoutMs?: number;
76
88
  /** Reports a native listener or setup failure; defaults to `console.warn`. */
@@ -110,7 +122,7 @@ export declare function capacitorLocalNotificationId(key: string): number;
110
122
  * - **Local reminders.** Inexact (`isExactNotification: false`), on the
111
123
  * topic's channel, at most 64 pending on iOS, keeping the nearest, one
112
124
  * operation at a time. A `fireAt` already past posts at once, as both
113
- * plugins do.
125
+ * plugins do. Android `listLocal` omits a row only when it is in the shade.
114
126
  * - **Fails closed.** Without either SDK the provider is `unsupported`;
115
127
  * without `push` it declares no remote delivery, without `local` no local
116
128
  * reminders.
@@ -83,7 +83,7 @@ export function capacitorLocalNotificationId(key) {
83
83
  * - **Local reminders.** Inexact (`isExactNotification: false`), on the
84
84
  * topic's channel, at most 64 pending on iOS, keeping the nearest, one
85
85
  * operation at a time. A `fireAt` already past posts at once, as both
86
- * plugins do.
86
+ * plugins do. Android `listLocal` omits a row only when it is in the shade.
87
87
  * - **Fails closed.** Without either SDK the provider is `unsupported`;
88
88
  * without `push` it declares no remote delivery, without `local` no local
89
89
  * reminders.
@@ -114,10 +114,18 @@ export function createCapacitorNotificationProvider(input) {
114
114
  // `configuration-required`, as a registry mismatch makes a provider.
115
115
  const optOutMissing = push !== undefined && local === undefined
116
116
  && mappedTopics.some((topic) => topic.purpose === 'advertising');
117
+ // A push-only shell whose messaging plugin cannot create channels would
118
+ // otherwise announce an FCM token for catalog channels that do not exist.
119
+ const channelsUncreatable = platform === 'android' && channels.length > 0
120
+ && push !== undefined && local === undefined && push.createChannel === undefined;
117
121
  const configurationIssue = optOutMissing
118
122
  ? 'Capacitor notifications: the catalog maps an advertising topic, whose opt-out action '
119
123
  + 'needs @capacitor/local-notifications; install both plugins.'
120
- : undefined;
124
+ : channelsUncreatable
125
+ ? 'Capacitor notifications: Android pushes name a catalog channel, and this push-only '
126
+ + 'shell has no createChannel. Install @capacitor/local-notifications, or a messaging '
127
+ + 'plugin that creates channels, before registering a token.'
128
+ : undefined;
121
129
  const assertConfigured = () => {
122
130
  if (configurationIssue !== undefined) {
123
131
  throw new NotificationError('NOTIFICATIONS_CONFIGURATION_REQUIRED', {
@@ -180,7 +188,20 @@ export function createCapacitorNotificationProvider(input) {
180
188
  // which also registers with APNs on iOS.
181
189
  let checkPermission;
182
190
  if (local !== undefined) {
183
- checkPermission = async () => (await local.checkPermissions()).display;
191
+ checkPermission = async () => {
192
+ const display = (await local.checkPermissions()).display;
193
+ // Android 13+ `checkPermissions` reads POST_NOTIFICATIONS and can
194
+ // answer granted after the player turns notifications off in settings.
195
+ // `areNotificationsEnabled` is that switch. On older Android the
196
+ // permission read already uses it, so a false answer agrees.
197
+ if (platform === 'android' && display === 'granted' && local.areEnabled !== undefined) {
198
+ const enabled = await local.areEnabled();
199
+ if (enabled.value === false) {
200
+ return 'denied';
201
+ }
202
+ }
203
+ return display;
204
+ };
184
205
  }
185
206
  else if (push !== undefined) {
186
207
  // Without the local plugin, Android 12 and lower read granted whatever
@@ -195,6 +216,8 @@ export function createCapacitorNotificationProvider(input) {
195
216
  askPermission = async () => (await local.requestPermissions()).display;
196
217
  }
197
218
  let tokenFailureReported = false;
219
+ /** Latest `tokenReceived`, so an older permission correction cannot publish last. */
220
+ let tokenGeneration = 0;
198
221
  let lastObserved;
199
222
  /**
200
223
  * The OS dialog in flight, whose answer settles `lastObserved`; a request
@@ -283,6 +306,12 @@ export function createCapacitorNotificationProvider(input) {
283
306
  return mappedTopic(stringOf(data[capacitorNotificationDataKeys.topic]));
284
307
  };
285
308
  const openedFromPush = (actionId, notification) => {
309
+ if (actionId === 'dismiss') {
310
+ // Firebase Messaging emits notificationActionPerformed for a swipe
311
+ // dismiss (`UNNotificationDismissActionIdentifier`) and retains it
312
+ // until a listener consumes it. That is not an open.
313
+ return;
314
+ }
286
315
  const data = isRecord(notification?.data) ? notification.data : {};
287
316
  const declared = declaredTopicOf(data);
288
317
  const topicId = declared?.id;
@@ -345,21 +374,60 @@ export function createCapacitorNotificationProvider(input) {
345
374
  }
346
375
  const { token } = event;
347
376
  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;
377
+ const generation = tokenGeneration + 1;
378
+ tokenGeneration = generation;
379
+ // A rotation must reach the runtime before this listener returns: the
380
+ // conformance harness registers the address from that synchronous
381
+ // event. The permission on it is the last read. A change made in
382
+ // system settings has no event, so a fresh read follows and replaces
383
+ // the announcement when the permission differs. Channels are awaited
384
+ // on that read; `register()` does not return a token until they exist.
385
+ const announced = lastObserved !== undefined && requesting === undefined
386
+ ? lastObserved
387
+ : undefined;
388
+ const announcedNow = announced !== undefined && androidChannelsReady;
389
+ if (announcedNow && announced !== undefined) {
390
+ dispatch({
391
+ type: 'endpoint-changed',
392
+ registration: registrationWith(token, announced),
393
+ });
356
394
  }
357
395
  void (requesting ?? Promise.resolve())
358
396
  .catch(() => undefined)
359
- .then(() => (lastObserved === undefined
360
- ? registrationOf(token)
361
- : registrationWith(token, lastObserved)))
362
- .then((registration) => dispatch({ type: 'endpoint-changed', registration }), reportError);
397
+ .then(async () => {
398
+ if (generation !== tokenGeneration) {
399
+ return undefined;
400
+ }
401
+ await ensureChannels();
402
+ if (generation !== tokenGeneration) {
403
+ return undefined;
404
+ }
405
+ return registrationOf(token);
406
+ })
407
+ .then((registration) => {
408
+ if (registration === undefined || generation !== tokenGeneration) {
409
+ return;
410
+ }
411
+ const permission = registration.address.kind === 'fcm-token'
412
+ ? registration.address.permission
413
+ : undefined;
414
+ if (announcedNow && permission === announced) {
415
+ return;
416
+ }
417
+ dispatch({ type: 'endpoint-changed', registration });
418
+ }, (error) => {
419
+ if (generation !== tokenGeneration) {
420
+ return;
421
+ }
422
+ // Channel creation can fail transiently. Keep this token: the
423
+ // foreground retry of `ensureChannels` publishes it once the
424
+ // channels exist. Dropping it leaves the backend on the previous
425
+ // address until the next registration or the seven-day refresh.
426
+ if (!androidChannelsReady) {
427
+ retainedToken = { token, generation };
428
+ }
429
+ reportError(error);
430
+ });
363
431
  }));
364
432
  listen(() => push.addListener('notificationReceived', (event) => {
365
433
  const data = isRecord(event?.notification?.data) ? event.notification.data : {};
@@ -402,6 +470,40 @@ export function createCapacitorNotificationProvider(input) {
402
470
  }
403
471
  };
404
472
  let channelsReady;
473
+ /** Android pushes name a channel; do not announce an address before it exists. */
474
+ let androidChannelsReady = platform !== 'android';
475
+ /**
476
+ * A `tokenReceived` whose publish failed because Android channels were not
477
+ * ready. Published once a later `ensureChannels` succeeds, so a transient
478
+ * channel failure does not leave the backend on the previous token.
479
+ */
480
+ let retainedToken;
481
+ const publishRetainedToken = () => {
482
+ const held = retainedToken;
483
+ if (held === undefined || !androidChannelsReady) {
484
+ return;
485
+ }
486
+ if (held.generation !== tokenGeneration) {
487
+ retainedToken = undefined;
488
+ return;
489
+ }
490
+ retainedToken = undefined;
491
+ void (async () => {
492
+ try {
493
+ const registration = await registrationOf(held.token);
494
+ if (held.generation !== tokenGeneration) {
495
+ return;
496
+ }
497
+ dispatch({ type: 'endpoint-changed', registration });
498
+ }
499
+ catch (error) {
500
+ if (held.generation === tokenGeneration) {
501
+ retainedToken = held;
502
+ reportError(error);
503
+ }
504
+ }
505
+ })();
506
+ };
405
507
  const ensureChannels = () => {
406
508
  channelsReady ??= (async () => {
407
509
  if (platform !== 'android') {
@@ -416,9 +518,12 @@ export function createCapacitorNotificationProvider(input) {
416
518
  for (const channel of channels) {
417
519
  await createChannel(channel);
418
520
  }
521
+ androidChannelsReady = true;
419
522
  setupFailureReported = false;
523
+ publishRetainedToken();
420
524
  })().catch((error) => {
421
525
  channelsReady = undefined;
526
+ androidChannelsReady = platform === 'android' ? false : androidChannelsReady;
422
527
  throw error;
423
528
  });
424
529
  return channelsReady;
@@ -488,6 +593,14 @@ export function createCapacitorNotificationProvider(input) {
488
593
  ? { supported: false, purposes: [], addressing: 'none', content: 'free-form' }
489
594
  : {
490
595
  supported: true,
596
+ // Same purposes as `referenceNotificationProviders.fcm`, which
597
+ // `createFcmProviderRegistration` publishes for android and ios.
598
+ // `notificationRegistryMismatch` requires that list exactly; omitting
599
+ // `advertising` on Android makes every standard pairing
600
+ // `configuration-required`. An Android FCM notification message still
601
+ // cannot carry the opt-out action: the catalog validator refuses an
602
+ // advertising topic mapped to `android` until the shell renders it
603
+ // (RFC "Provider profiles", Capacitor).
491
604
  purposes: ['service', 'requested', 'advertising'],
492
605
  addressing: 'device',
493
606
  content: 'free-form',
@@ -517,6 +630,66 @@ export function createCapacitorNotificationProvider(input) {
517
630
  }
518
631
  return { all, ours: sortEntries(ours) };
519
632
  };
633
+ /** Ids currently in the shade. Empty when the plugin cannot say, so a deferred alarm stays visible. */
634
+ const deliveredIds = async (sdk) => {
635
+ const read = sdk.getDeliveredNotifications;
636
+ if (read === undefined) {
637
+ return new Set();
638
+ }
639
+ try {
640
+ const result = await read();
641
+ const notifications = Array.isArray(result?.notifications) ? result.notifications : [];
642
+ const ids = new Set();
643
+ for (const notification of notifications) {
644
+ if (typeof notification?.id === 'number') {
645
+ ids.add(notification.id);
646
+ }
647
+ }
648
+ return ids;
649
+ }
650
+ catch {
651
+ // A failed shade read cannot tell a fired row from a deferred alarm.
652
+ // Hide nothing, as when the method is absent, so cancellation still
653
+ // reaches every stored row.
654
+ return new Set();
655
+ }
656
+ };
657
+ /** How long to wait for an iOS `center.add` that the plugin already accepted. */
658
+ const scheduleConfirmBudgetMs = 2_000;
659
+ /** First pause after the immediate re-read; later pauses double up to the max. */
660
+ const scheduleConfirmInitialDelayMs = 25;
661
+ const scheduleConfirmMaxDelayMs = 250;
662
+ /**
663
+ * iOS `LocalNotifications.schedule` resolves before `center.add` completes.
664
+ * The pending row for this id can still be the reminder being replaced, so
665
+ * a match is the requested key, topic, `fireAt`, and route — not the id alone.
666
+ * Returns whether that row appeared before the wall-clock budget elapsed.
667
+ */
668
+ const confirmScheduled = async (sdk, expected) => {
669
+ const budget = input.scheduleConfirmBudgetMs ?? scheduleConfirmBudgetMs;
670
+ const started = Date.now();
671
+ let delay = 0;
672
+ for (;;) {
673
+ if (delay > 0) {
674
+ const remaining = budget - (Date.now() - started);
675
+ if (remaining <= 0) {
676
+ return false;
677
+ }
678
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay, remaining)));
679
+ }
680
+ const pending = await callSdk(() => sdk.getPending());
681
+ const notifications = Array.isArray(pending?.notifications) ? pending.notifications : [];
682
+ if (notifications.some((entry) => matchesScheduled(entry, expected))) {
683
+ return true;
684
+ }
685
+ if (Date.now() - started >= budget) {
686
+ return false;
687
+ }
688
+ delay = delay === 0
689
+ ? scheduleConfirmInitialDelayMs
690
+ : Math.min(delay * 2, scheduleConfirmMaxDelayMs);
691
+ }
692
+ };
520
693
  /**
521
694
  * Schedules a validated, declared, non-advertising reminder once permission
522
695
  * and channels are in place; runs inside `serialized`.
@@ -546,18 +719,38 @@ export function createCapacitorNotificationProvider(input) {
546
719
  }
547
720
  const id = existing?.id ?? freeId(checked.key, pending.all);
548
721
  const route = checked.route ?? declared.route;
722
+ const expected = {
723
+ id,
724
+ key: checked.key,
725
+ topic: checked.topic,
726
+ fireAt,
727
+ title: checked.title,
728
+ body: checked.body,
729
+ ...(route === undefined ? {} : { route }),
730
+ };
549
731
  try {
550
732
  await callSdk(() => sdk.schedule({
551
733
  notifications: [schemaOf({ ...checked, id, fireAt, route })],
552
734
  }));
553
735
  }
554
736
  catch (error) {
555
- // The new reminder failed after the farthest gave way: put that one back.
737
+ // The plugin rejected the schedule after the farthest gave way: put that one back.
556
738
  if (evicted !== undefined) {
557
739
  await restore(sdk, evicted);
558
740
  }
559
741
  throw error;
560
742
  }
743
+ // iOS `schedule` resolves before `center.add` finishes, and a full
744
+ // center silently drops one. A future reminder is confirmed when the
745
+ // pending row carries this key, topic, time, and route. An add still missing
746
+ // when the wait ends is unconfirmed, not a failure: `center.add` has no
747
+ // deadline, and throwing would restore the evicted reminder into a slot
748
+ // the add may still take. An already-due one posts at once and does not
749
+ // have to stay pending.
750
+ const nowMs = (input.now ?? Date.now)();
751
+ if (Date.parse(fireAt) > nowMs) {
752
+ await confirmScheduled(sdk, expected);
753
+ }
561
754
  return { status: existing === undefined ? 'scheduled' : 'replaced' };
562
755
  };
563
756
  /** One local notification as this provider writes it, for a new or a restored reminder. */
@@ -696,6 +889,9 @@ export function createCapacitorNotificationProvider(input) {
696
889
  : {
697
890
  async register() {
698
891
  assertConfigured();
892
+ // Do not hand the backend a token until the Android channels
893
+ // exist. A failed create is retryable and returns no token.
894
+ await callSdk(ensureChannels);
699
895
  let value;
700
896
  try {
701
897
  value = (await push.getToken()).token;
@@ -762,8 +958,24 @@ export function createCapacitorNotificationProvider(input) {
762
958
  });
763
959
  },
764
960
  listLocal() {
765
- return serialized(async () => (await readPending(local)).ours
766
- .map(({ key, topic, fireAt }) => ({ key, topic, fireAt })));
961
+ return serialized(async () => {
962
+ const pending = await readPending(local);
963
+ // Android `getPending` keeps fired one-shots in storage. A row
964
+ // in the shade has fired. A past `fireAt` that is not there can
965
+ // still be an alarm Doze has not delivered; cancellation reads
966
+ // this list, so that row stays. Without the delivered read a
967
+ // timestamp cannot tell those apart, so every stored row stays.
968
+ // iOS removes a request from pending once it fires, and a
969
+ // replacement reuses the stable id while the delivered copy can
970
+ // still sit in Notification Center, so the shade filter is an
971
+ // Android-only storage workaround.
972
+ const delivered = platform === 'android'
973
+ ? await deliveredIds(local)
974
+ : new Set();
975
+ return pending.ours
976
+ .filter((entry) => !delivered.has(entry.id))
977
+ .map(({ key, topic, fireAt }) => ({ key, topic, fireAt }));
978
+ });
767
979
  },
768
980
  }),
769
981
  async openSettings() {
@@ -912,6 +1124,11 @@ function toPermissionState(state) {
912
1124
  case 'granted':
913
1125
  return 'granted';
914
1126
  case 'prompt':
1127
+ case 'provisional':
1128
+ case 'ephemeral':
1129
+ // Provisional and ephemeral can still be upgraded by a prompt. The
1130
+ // installed plugins collapse both to `granted`; a shell that reports
1131
+ // the raw status is not treated as already granted.
915
1132
  return 'prompt';
916
1133
  case 'prompt-with-rationale':
917
1134
  return 'prompt-with-rationale';
@@ -925,6 +1142,26 @@ function toPermissionState(state) {
925
1142
  });
926
1143
  }
927
1144
  }
1145
+ /**
1146
+ * Whether `getPending` is showing the reminder just scheduled, not an older
1147
+ * request that still holds the same native id.
1148
+ */
1149
+ function matchesScheduled(notification, expected) {
1150
+ if (notification.id !== expected.id || !isRecord(notification.extra)) {
1151
+ return false;
1152
+ }
1153
+ const { extra } = notification;
1154
+ if (stringOf(extra[extraKeys.key]) !== expected.key
1155
+ || stringOf(extra[extraKeys.topic]) !== expected.topic
1156
+ || stringOf(extra[extraKeys.fireAt]) !== expected.fireAt
1157
+ || safeRoute(extra[extraKeys.route]) !== expected.route) {
1158
+ return false;
1159
+ }
1160
+ if (typeof notification.title === 'string' && notification.title !== expected.title) {
1161
+ return false;
1162
+ }
1163
+ return typeof notification.body !== 'string' || notification.body === expected.body;
1164
+ }
928
1165
  /** This provider's key, topic, and time from a local notification's `extra`, or `undefined` for another one. */
929
1166
  function readLocalEntry(notification) {
930
1167
  const extra = notification?.extra;
@@ -1,4 +1,11 @@
1
1
  import { type PermissionState, type PluginListenerHandle } from '@capacitor/core';
2
+ /**
3
+ * Permission strings the plugins report. The installed 8.x plugins collapse
4
+ * iOS `.provisional` and `.ephemeral` to `granted`; a shell that reports the
5
+ * raw status uses the extra values, which the provider treats as still
6
+ * promptable.
7
+ */
8
+ export type NotificationSdkPermissionState = PermissionState | 'provisional' | 'ephemeral';
2
9
  /** A notification as `@capacitor-firebase/messaging` reports it. */
3
10
  export interface PushSdkNotification {
4
11
  readonly id?: string;
@@ -29,11 +36,11 @@ export interface NotificationSdkChannel {
29
36
  export interface PushSdk {
30
37
  /** On Android 12 and lower the plugin answers `granted` whatever the system setting is. */
31
38
  checkPermissions(): Promise<{
32
- readonly receive: PermissionState;
39
+ readonly receive: NotificationSdkPermissionState;
33
40
  }>;
34
41
  /** Shows the OS dialog when the OS still allows it; on iOS it also registers with APNs. */
35
42
  requestPermissions(): Promise<{
36
- readonly receive: PermissionState;
43
+ readonly receive: NotificationSdkPermissionState;
37
44
  }>;
38
45
  /** Rejects while Firebase cannot issue a token yet (iOS before APNs registration, offline). */
39
46
  getToken(): Promise<{
@@ -95,10 +102,18 @@ export interface LocalNotificationSdkActionType {
95
102
  export interface LocalNotificationSdk {
96
103
  /** Reflects the system setting on every Android version, unlike the push plugin. */
97
104
  checkPermissions(): Promise<{
98
- readonly display: PermissionState;
105
+ readonly display: NotificationSdkPermissionState;
99
106
  }>;
100
107
  requestPermissions(): Promise<{
101
- readonly display: PermissionState;
108
+ readonly display: NotificationSdkPermissionState;
109
+ }>;
110
+ /**
111
+ * `NotificationManager.areNotificationsEnabled()`. Absent on a partial
112
+ * fake. On Android 13+ `checkPermissions` reads `POST_NOTIFICATIONS` and
113
+ * can answer `granted` while this is false.
114
+ */
115
+ areEnabled?(): Promise<{
116
+ readonly value: boolean;
102
117
  }>;
103
118
  schedule(options: {
104
119
  readonly notifications: LocalNotificationSdkSchema[];
@@ -111,6 +126,17 @@ export interface LocalNotificationSdk {
111
126
  getPending(): Promise<{
112
127
  readonly notifications: readonly LocalNotificationSdkEntry[];
113
128
  }>;
129
+ /**
130
+ * Notifications currently visible in the shade. Android `getPending` also
131
+ * keeps a fired one-shot in storage; a row here has fired. Absent on a
132
+ * partial fake, which then cannot tell that row from an alarm Doze has
133
+ * not delivered yet.
134
+ */
135
+ getDeliveredNotifications?(): Promise<{
136
+ readonly notifications: readonly {
137
+ readonly id: number;
138
+ }[];
139
+ }>;
114
140
  createChannel(channel: NotificationSdkChannel): Promise<void>;
115
141
  /**
116
142
  * On iOS this sets the app's notification categories, which remote
@@ -0,0 +1,14 @@
1
+ import { type CapacitorGameServicesPlugin } from '@mpgd/capacitor-game-services';
2
+ /** The part of `@mpgd/capacitor-game-services` the settings opener calls. */
3
+ export type CapacitorNotificationSettingsPlugin = Pick<CapacitorGameServicesPlugin, 'openNotificationSettings'>;
4
+ /**
5
+ * Opens this app's notification settings through
6
+ * `@mpgd/capacitor-game-services` (Android 8+ app notification settings, iOS
7
+ * 16+ notification settings, each app's settings page on older versions).
8
+ * Pass it as the provider's `openSettings`, which then declares
9
+ * `permission.settings: true` and waits for the player to come back. It
10
+ * throws when the system could not open the screen, and the plugin rejects
11
+ * it as unimplemented in a shell built with a plugin older than the method;
12
+ * the provider reports either failure and answers `unavailable`.
13
+ */
14
+ export declare function openCapacitorNotificationSettings(plugin?: CapacitorNotificationSettingsPlugin): Promise<void>;
@@ -0,0 +1,18 @@
1
+ import { CapacitorGameServices, } from '@mpgd/capacitor-game-services';
2
+ /**
3
+ * Opens this app's notification settings through
4
+ * `@mpgd/capacitor-game-services` (Android 8+ app notification settings, iOS
5
+ * 16+ notification settings, each app's settings page on older versions).
6
+ * Pass it as the provider's `openSettings`, which then declares
7
+ * `permission.settings: true` and waits for the player to come back. It
8
+ * throws when the system could not open the screen, and the plugin rejects
9
+ * it as unimplemented in a shell built with a plugin older than the method;
10
+ * the provider reports either failure and answers `unavailable`.
11
+ */
12
+ export async function openCapacitorNotificationSettings(plugin = CapacitorGameServices) {
13
+ const result = await plugin.openNotificationSettings();
14
+ if (typeof result !== 'object' || result === null
15
+ || result.opened !== true) {
16
+ throw new Error('The notification settings screen could not be opened.');
17
+ }
18
+ }
@@ -1,8 +1,9 @@
1
1
  import { type CreateCapacitorNotificationProviderInput } from './notifications-provider.js';
2
2
  import type { CapacitorNotificationServiceProvider } from './providers.js';
3
- export { capacitorNotificationPluginNames, loadCapacitorNotificationSdks, type CapacitorNotificationSdkRuntime, type CapacitorNotificationSdks, type LoadCapacitorNotificationSdksInput, type LocalNotificationSdk, type LocalNotificationSdkActionType, type LocalNotificationSdkEntry, type LocalNotificationSdkSchema, type NotificationSdkChannel, type PushSdk, type PushSdkActionEvent, type PushSdkNotification, } from './notifications-sdk.js';
3
+ export { capacitorNotificationPluginNames, loadCapacitorNotificationSdks, type CapacitorNotificationSdkRuntime, type CapacitorNotificationSdks, type LoadCapacitorNotificationSdksInput, type LocalNotificationSdk, type LocalNotificationSdkActionType, type LocalNotificationSdkEntry, type LocalNotificationSdkSchema, type NotificationSdkChannel, type NotificationSdkPermissionState, type PushSdk, type PushSdkActionEvent, type PushSdkNotification, } from './notifications-sdk.js';
4
4
  export { capacitorAdvertisingActionTypeId, capacitorIosMaxPendingLocalNotifications, capacitorLocalNotificationId, capacitorNotificationChannelId, capacitorNotificationDataKeys, capacitorNotificationProviderId, capacitorSettingsLeaveTimeoutMs, capacitorSettingsReturnTimeoutMs, createCapacitorNotificationProvider, type CapacitorNotificationAppEvents, type CapacitorNotificationChannelSettings, type CapacitorNotificationProvider, type CreateCapacitorNotificationProviderInput, } from './notifications-provider.js';
5
5
  export { createBridgeNotificationProvider, createNotificationProviderBridge, createNotificationServiceProvider, createNotificationSubscriptionAdapter, type CreateBridgeNotificationProviderInput, type CreateNotificationProviderBridgeInput, type CreateNotificationServiceProviderInput, type NotificationProviderBridge, } from './notifications-bridge.js';
6
+ export { openCapacitorNotificationSettings, type CapacitorNotificationSettingsPlugin, } from './notifications-settings.js';
6
7
  export type { CapacitorNotificationServiceProvider, CapacitorNotificationsV2Service, } from './providers.js';
7
8
  export interface CreateCapacitorNotificationServiceProviderInput extends CreateCapacitorNotificationProviderInput {
8
9
  /** The registry id; 'capacitor-notifications' when absent. */
@@ -8,6 +8,7 @@ import { createCapacitorNotificationProvider, } from './notifications-provider.j
8
8
  export { capacitorNotificationPluginNames, loadCapacitorNotificationSdks, } from './notifications-sdk.js';
9
9
  export { capacitorAdvertisingActionTypeId, capacitorIosMaxPendingLocalNotifications, capacitorLocalNotificationId, capacitorNotificationChannelId, capacitorNotificationDataKeys, capacitorNotificationProviderId, capacitorSettingsLeaveTimeoutMs, capacitorSettingsReturnTimeoutMs, createCapacitorNotificationProvider, } from './notifications-provider.js';
10
10
  export { createBridgeNotificationProvider, createNotificationProviderBridge, createNotificationServiceProvider, createNotificationSubscriptionAdapter, } from './notifications-bridge.js';
11
+ export { openCapacitorNotificationSettings, } from './notifications-settings.js';
11
12
  /**
12
13
  * The provider and its registry entry in one call, with the same topics, for
13
14
  * `createCapacitorPlatformGateway({ providers: [...] })`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-capacitor",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "Capacitor platform adapter for mpgd Android and iOS shells.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,11 +43,11 @@
43
43
  ],
44
44
  "dependencies": {
45
45
  "@capacitor/app": "8.1.1",
46
- "@mpgd/platform": "0.25.0",
47
- "@mpgd/bridge": "0.14.0",
48
- "@mpgd/target-config": "0.23.3",
49
- "@mpgd/capacitor-game-services": "0.7.3",
50
- "@mpgd/game-services": "0.25.1"
46
+ "@mpgd/capacitor-game-services": "0.8.1",
47
+ "@mpgd/bridge": "0.14.2",
48
+ "@mpgd/game-services": "0.27.0",
49
+ "@mpgd/target-config": "0.23.5",
50
+ "@mpgd/platform": "0.27.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@capacitor-community/admob": "^8.1.0",
@@ -74,7 +74,7 @@
74
74
  "ttsc": "0.32.1",
75
75
  "typescript": "7.0.2",
76
76
  "vitest": "^4.0.0",
77
- "@mpgd/game-runtime": "0.7.3"
77
+ "@mpgd/game-runtime": "0.7.5"
78
78
  },
79
79
  "main": "./dist/index.js",
80
80
  "types": "./dist/index.d.ts",