@mpgd/adapter-capacitor 0.12.0 → 0.12.2
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 +42 -10
- package/dist/notifications-provider.d.ts +13 -1
- package/dist/notifications-provider.js +255 -18
- package/dist/notifications-sdk.d.ts +30 -4
- package/dist/notifications.d.ts +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -208,6 +208,10 @@ const notifications = createCapacitorNotificationServiceProvider({
|
|
|
208
208
|
const gateway = createCapacitorPlatformGateway({ ..., providers: [notifications] });
|
|
209
209
|
```
|
|
210
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
|
+
-->
|
|
211
215
|
Create it once, early in startup: it registers its native listeners when it
|
|
212
216
|
is created, which is how a cold-start open reaches it.
|
|
213
217
|
|
|
@@ -240,7 +244,12 @@ is created, which is how a cold-start open reaches it.
|
|
|
240
244
|
`NOTIFICATIONS_V2_UNAVAILABLE` before it pauses the game.
|
|
241
245
|
- **Permission.** One OS permission for the app. The provider reads it through
|
|
242
246
|
the local plugin, which reflects the system setting on Android 12 and lower
|
|
243
|
-
where the push plugin answers `granted` regardless.
|
|
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
|
|
244
253
|
`blocked`: after one denial on iOS (`promptLimit` 1) and after the second on
|
|
245
254
|
Android 13+ (`promptLimit` 2; pass `androidPromptLimit: 1` for an app that
|
|
246
255
|
targets SDK 32 or lower). Only `requestPermission` shows the dialog, and a
|
|
@@ -252,6 +261,15 @@ is created, which is how a cold-start open reaches it.
|
|
|
252
261
|
schedules locally. Otherwise it reads the permission first and answers
|
|
253
262
|
`permission-required` without calling the plugin, whose own `schedule` would
|
|
254
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`.
|
|
255
273
|
- Reminders are inexact (`isExactNotification: false`; the plugin's default
|
|
256
274
|
would open the exact-alarm settings screen) but may fire in Android's Doze
|
|
257
275
|
mode (`allowWhileIdle: true`; Android lets such alarms fire about once per
|
|
@@ -261,15 +279,18 @@ is created, which is how a cold-start open reaches it.
|
|
|
261
279
|
the topic's Android channel. Operations run one at a time, in call order.
|
|
262
280
|
- iOS keeps at most 64 pending, so the provider keeps the nearest: at the
|
|
263
281
|
ceiling it cancels the farthest of its reminders before it schedules a
|
|
264
|
-
nearer one, and puts the farthest back when that schedule
|
|
265
|
-
|
|
266
|
-
|
|
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.
|
|
267
286
|
- **Channels.** Each topic posts to its mapping's `channelId`, or to
|
|
268
287
|
`mpgd-service`, `mpgd-requested`, or `mpgd-advertising` by purpose
|
|
269
288
|
(`capacitorNotificationChannelId`). Creating the provider throws when a
|
|
270
289
|
channel would mix advertising with other topics, so a player can always turn
|
|
271
290
|
advertising off in system settings. Name the channels with the `channels`
|
|
272
|
-
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.
|
|
273
294
|
- **Topics.** Only topics this target maps count. A topic the catalog does
|
|
274
295
|
not declare, or declares without a mapping for the target, reads
|
|
275
296
|
`unsupported`, answers `unavailable` to a request and `unsupported` to
|
|
@@ -284,8 +305,12 @@ is created, which is how a cold-start open reaches it.
|
|
|
284
305
|
the like), reach the game as untrusted `data`. A route is kept only when
|
|
285
306
|
`isRootRelativeNotificationRoute` accepts it, and a topic only when the
|
|
286
307
|
catalog declares it. Nothing is granted from an open. An open that arrives
|
|
287
|
-
before the first subscriber is marked `cold
|
|
288
|
-
|
|
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`.
|
|
289
314
|
- **Advertising opt-out.** For a catalog with an advertising topic, the
|
|
290
315
|
provider registers the action type `mpgd-advertising`
|
|
291
316
|
(`capacitorAdvertisingActionTypeId`) with the `advertising-opt-out` action
|
|
@@ -298,8 +323,10 @@ is created, which is how a cold-start open reaches it.
|
|
|
298
323
|
in its `topic` data key; without that key it opens nothing.
|
|
299
324
|
- An Android FCM notification message cannot carry actions, so an Android
|
|
300
325
|
advertising push needs the shell to render it natively with the action,
|
|
301
|
-
which is not built yet (a follow-up after C1).
|
|
302
|
-
|
|
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
|
|
303
330
|
opt-out action the Korean rules require (see the RFC's advertising
|
|
304
331
|
rules). The kit's starter maps its sample advertising topic to iOS only.
|
|
305
332
|
- **Settings.** Neither plugin opens the notification settings, so the
|
|
@@ -317,7 +344,12 @@ is created, which is how a cold-start open reaches it.
|
|
|
317
344
|
five seconds when the app never left.
|
|
318
345
|
- **Endpoints.** `register()` answers the FCM token with the device
|
|
319
346
|
permission, locale, and time zone, or `null` while Firebase cannot issue one
|
|
320
|
-
yet; a token Firebase issues later arrives as `endpoint-changed`.
|
|
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
|
|
321
353
|
registers FCM tokens only: `fcm-installation` addresses are reserved,
|
|
322
354
|
because FCM HTTP v1 cannot send to a Firebase installation id and the
|
|
323
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
|
-
:
|
|
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 () =>
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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(() =>
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
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 () =>
|
|
766
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
105
|
+
readonly display: NotificationSdkPermissionState;
|
|
99
106
|
}>;
|
|
100
107
|
requestPermissions(): Promise<{
|
|
101
|
-
readonly display:
|
|
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
|
package/dist/notifications.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
export { openCapacitorNotificationSettings, type CapacitorNotificationSettingsPlugin, } from './notifications-settings.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/adapter-capacitor",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
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/capacitor-game-services": "0.8.
|
|
47
|
-
"@mpgd/
|
|
48
|
-
"@mpgd/
|
|
49
|
-
"@mpgd/
|
|
50
|
-
"@mpgd/target-config": "0.23.
|
|
46
|
+
"@mpgd/capacitor-game-services": "0.8.2",
|
|
47
|
+
"@mpgd/game-services": "0.27.1",
|
|
48
|
+
"@mpgd/bridge": "0.14.3",
|
|
49
|
+
"@mpgd/platform": "0.27.1",
|
|
50
|
+
"@mpgd/target-config": "0.23.6"
|
|
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.
|
|
77
|
+
"@mpgd/game-runtime": "0.7.6"
|
|
78
78
|
},
|
|
79
79
|
"main": "./dist/index.js",
|
|
80
80
|
"types": "./dist/index.d.ts",
|