@chipmobilesdk/rn-notification 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/README.md +108 -12
- package/package.json +10 -2
- package/skills/sdk-rn-notification/SKILL.md +6 -5
- package/src/advanced.ts +35 -0
- package/src/channel/migrate.ts +176 -158
- package/src/channel/tone-map.ts +2 -10
- package/src/contract/errors.ts +21 -6
- package/src/contract/hash.ts +26 -0
- package/src/diagnostics/events.ts +10 -1
- package/src/diagnostics/redact.ts +9 -26
- package/src/diagnostics/snapshot.ts +34 -1
- package/src/engine/adapter.ts +21 -1
- package/src/engine/notify-kit/events.ts +155 -0
- package/src/engine/notify-kit/module.ts +148 -0
- package/src/engine/notify-kit/platform/android.ts +172 -0
- package/src/engine/notify-kit/platform/index.ts +55 -0
- package/src/engine/notify-kit/platform/ios.ts +54 -0
- package/src/engine/notify-kit/platform/types.ts +40 -0
- package/src/engine/notify-kit-engine.ts +96 -468
- package/src/index.ts +9 -0
- package/src/initialize.ts +10 -3
- package/src/notifications.ts +195 -0
- package/src/permission/index.ts +65 -4
- package/src/push/fcm-provider.ts +28 -11
- package/src/push/gate.ts +27 -5
- package/src/reconcile/diff.ts +38 -27
- package/src/reconcile/index.ts +13 -4
- package/src/recurrence/index.ts +2 -1
- package/src/repeat/expiry.ts +2 -6
- package/src/repeat/index.ts +45 -1
- package/src/repeat/marker.ts +1 -22
- package/src/repeat/place.ts +74 -35
- package/src/schedule/anchor.ts +35 -19
- package/src/schedule/index.ts +10 -4
- package/src/schedule/partition.ts +0 -19
- package/src/schedule/validate-identifier.ts +8 -6
- package/src/schedule/writer.ts +13 -4
- package/src/state/caps.ts +28 -3
- package/src/state/interaction-buffer.ts +30 -2
- package/src/state/mmkv-store.ts +54 -37
- package/src/state/permission-history.ts +25 -0
- package/src/state/record.ts +114 -20
- package/src/state/store.ts +3 -0
- package/src/testing/fake-engine.ts +17 -1
package/src/state/mmkv-store.ts
CHANGED
|
@@ -16,19 +16,35 @@
|
|
|
16
16
|
* README. It is the app's manifest, so it is the app's declaration to make.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
createDiagnosticChannel,
|
|
21
|
+
currentPlatform,
|
|
22
|
+
lazyPeer,
|
|
23
|
+
openDurable,
|
|
24
|
+
type MmkvLike,
|
|
25
|
+
} from '@chipmobilesdk/rn-core';
|
|
26
|
+
|
|
27
|
+
import { DIAGNOSTIC_CODES } from '../contract/errors';
|
|
20
28
|
import type { StateStore } from './store';
|
|
21
|
-
import { createMemoryStore } from './store';
|
|
22
29
|
|
|
23
30
|
const INSTANCE_ID = 'chipmobilesdk.notification';
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const mmkv = lazyPeer('react-native-mmkv', () => {
|
|
33
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
34
|
+
try {
|
|
35
|
+
return require('react-native-mmkv') as {
|
|
36
|
+
createMMKV: (config: { id: string; path?: string }) => MmkvLike;
|
|
37
|
+
};
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
throw cause;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const channel = createDiagnosticChannel('rn-notification');
|
|
44
|
+
|
|
45
|
+
export interface MmkvStoreOptions {
|
|
46
|
+
/** Told once, with the reason, when the store falls back to memory. */
|
|
47
|
+
readonly onDegraded?: (cause: unknown) => void;
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
/**
|
|
@@ -36,40 +52,41 @@ interface MmkvLike {
|
|
|
36
52
|
*
|
|
37
53
|
* The fallback is deliberate: a missing optional native module degrades
|
|
38
54
|
* deduplication and cold-start buffering to session scope, which is a
|
|
39
|
-
* documented degradation. It must never stop notifications being delivered
|
|
55
|
+
* documented degradation. It must never stop notifications being delivered, and
|
|
56
|
+
* it is never silent (NOT-15): it reports `STATE_STORE_DEGRADED` to the app's SDK
|
|
57
|
+
* sinks and to `onDegraded`, and the store reports `durable: false`.
|
|
40
58
|
*/
|
|
41
|
-
export function createMmkvStore(): StateStore {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
59
|
+
export function createMmkvStore(options: MmkvStoreOptions = {}): StateStore {
|
|
60
|
+
const port = openDurable(
|
|
61
|
+
() =>
|
|
62
|
+
// v4's factory: up to 0.2.0 this called the v3 `new MMKV()`, which v4 no
|
|
63
|
+
// longer exports as a value, so every device silently got memory.
|
|
64
|
+
mmkv
|
|
65
|
+
.require(peer => new Error(`${peer} is not installed`))
|
|
66
|
+
.createMMKV({
|
|
67
|
+
id: INSTANCE_ID,
|
|
68
|
+
// Caches is backup-excluded by platform design; see the note above.
|
|
69
|
+
...(currentPlatform() === 'ios'
|
|
70
|
+
? { path: 'Library/Caches/chipmobilesdk-notification' }
|
|
71
|
+
: {}),
|
|
72
|
+
}),
|
|
73
|
+
cause => {
|
|
74
|
+
channel.emit(DIAGNOSTIC_CODES.STATE_STORE_DEGRADED, 'warn');
|
|
75
|
+
options.onDegraded?.(cause);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
62
78
|
return {
|
|
63
|
-
getString: key =>
|
|
64
|
-
setString: (key, value) =>
|
|
65
|
-
delete: key =>
|
|
66
|
-
clearAll: () =>
|
|
79
|
+
getString: key => port.getString(key),
|
|
80
|
+
setString: (key, value) => port.set(key, value),
|
|
81
|
+
delete: key => port.remove(key),
|
|
82
|
+
clearAll: () => port.clearAll(),
|
|
67
83
|
approximateBytes: () => {
|
|
68
84
|
let total = 0;
|
|
69
|
-
for (const key of
|
|
70
|
-
total += key.length + (
|
|
85
|
+
for (const key of port.keys()) {
|
|
86
|
+
total += key.length + (port.getString(key)?.length ?? 0);
|
|
71
87
|
}
|
|
72
88
|
return total;
|
|
73
89
|
},
|
|
90
|
+
durable: port.durable,
|
|
74
91
|
};
|
|
75
92
|
}
|
|
@@ -39,3 +39,28 @@ export function recordRequested(
|
|
|
39
39
|
{ at: now, key: target, requested: true },
|
|
40
40
|
]);
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/** The device-wide key: a refusal is the user's answer to the app, not to one account. */
|
|
44
|
+
const DEVICE = '#device';
|
|
45
|
+
|
|
46
|
+
/** How many requests for `kind` the user has refused on this device. */
|
|
47
|
+
export function denialCount(state: StateManager, kind: PermissionKind): number {
|
|
48
|
+
const target = key(kind, DEVICE);
|
|
49
|
+
return state.read().permissionHistory.find(entry => entry.key === target)?.denials ?? 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function recordDenial(state: StateManager, kind: PermissionKind, now: number): void {
|
|
53
|
+
const target = key(kind, DEVICE);
|
|
54
|
+
const denials = denialCount(state, kind) + 1;
|
|
55
|
+
state.update('permissionHistory', current => [
|
|
56
|
+
...current.filter(entry => entry.key !== target),
|
|
57
|
+
{ at: now, key: target, requested: true, denials },
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Forgets refusals once the permission is seen granted: the next refusal starts over. */
|
|
62
|
+
export function clearDenials(state: StateManager, kind: PermissionKind): void {
|
|
63
|
+
if (denialCount(state, kind) === 0) return;
|
|
64
|
+
const target = key(kind, DEVICE);
|
|
65
|
+
state.update('permissionHistory', current => current.filter(entry => entry.key !== target));
|
|
66
|
+
}
|
package/src/state/record.ts
CHANGED
|
@@ -16,7 +16,23 @@ import type { AnchoringMode, RoutingPayload } from '../contract/notification';
|
|
|
16
16
|
import type { CollectionHealth, StateHealth } from '../contract/diagnostics';
|
|
17
17
|
import { DIAGNOSTIC_CODES, type DiagnosticCode } from '../contract/errors';
|
|
18
18
|
|
|
19
|
+
/** The single record before 0.6.0; read once to migrate, then deleted. */
|
|
19
20
|
const STATE_KEY = 'chipmobilesdk.notification.state';
|
|
21
|
+
/**
|
|
22
|
+
* Since 0.6.0 each collection has its own key, plus one for the schema version and
|
|
23
|
+
* summaries, so recording one dedup entry writes the dedup ledger and nothing else
|
|
24
|
+
* (NOT-16). Before, every update serialized the whole record — the 500-entry dedup
|
|
25
|
+
* ledger included — synchronously on the JS thread.
|
|
26
|
+
*/
|
|
27
|
+
const META_KEY = `${STATE_KEY}:meta`;
|
|
28
|
+
const COLLECTIONS: readonly CollectionName[] = [
|
|
29
|
+
'dedupLedger',
|
|
30
|
+
'interactionBuffer',
|
|
31
|
+
'migrationLedger',
|
|
32
|
+
'seriesLedger',
|
|
33
|
+
'permissionHistory',
|
|
34
|
+
];
|
|
35
|
+
const keyFor = (collection: CollectionName) => `${STATE_KEY}:${collection}`;
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
38
|
* Bumped whenever the shape below changes. A mismatch discards and rebuilds —
|
|
@@ -35,6 +51,10 @@ export interface BufferedInteraction extends Timestamped {
|
|
|
35
51
|
readonly kind: 'tap' | 'action' | 'dismiss';
|
|
36
52
|
readonly notificationId: string;
|
|
37
53
|
readonly actionId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Only ever present in a record written before 0.5: typed replies are no
|
|
56
|
+
* longer persisted (interaction-buffer.ts).
|
|
57
|
+
*/
|
|
38
58
|
readonly inputText?: string;
|
|
39
59
|
/** The one content-bearing thing persisted. Evicted the moment it is delivered. */
|
|
40
60
|
readonly routing?: RoutingPayload;
|
|
@@ -76,6 +96,8 @@ export interface SeriesEntry extends Timestamped {
|
|
|
76
96
|
export interface PermissionHistoryEntry extends Timestamped {
|
|
77
97
|
readonly key: string;
|
|
78
98
|
readonly requested: boolean;
|
|
99
|
+
/** Requests the user refused, so a permanent denial can be told apart (0.6.0). */
|
|
100
|
+
readonly denials?: number;
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
export interface StateRecord {
|
|
@@ -130,40 +152,77 @@ export class StateManager {
|
|
|
130
152
|
}
|
|
131
153
|
|
|
132
154
|
private load(): StateRecord {
|
|
133
|
-
const raw = this.store.getString(STATE_KEY);
|
|
134
|
-
if (raw === undefined) {
|
|
135
|
-
return emptyRecord();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
155
|
try {
|
|
139
|
-
const
|
|
156
|
+
const meta = this.store.getString(META_KEY);
|
|
157
|
+
if (meta === undefined) {
|
|
158
|
+
return this.migrateLegacy();
|
|
159
|
+
}
|
|
160
|
+
const parsed = JSON.parse(meta) as Partial<StateRecord>;
|
|
140
161
|
if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
|
|
141
162
|
return this.discardAndRebuild('schema-version-mismatch', {
|
|
142
163
|
found: parsed.schemaVersion,
|
|
143
164
|
expected: STATE_SCHEMA_VERSION,
|
|
144
165
|
});
|
|
145
166
|
}
|
|
146
|
-
|
|
167
|
+
const record: Record<string, unknown> = {
|
|
147
168
|
schemaVersion: STATE_SCHEMA_VERSION,
|
|
148
|
-
dedupLedger: parsed.dedupLedger ?? [],
|
|
149
|
-
interactionBuffer: parsed.interactionBuffer ?? [],
|
|
150
|
-
migrationLedger: parsed.migrationLedger ?? [],
|
|
151
|
-
seriesLedger: parsed.seriesLedger ?? [],
|
|
152
|
-
permissionHistory: parsed.permissionHistory ?? [],
|
|
153
169
|
lastReconcileSummary: parsed.lastReconcileSummary,
|
|
154
170
|
lastMaintenanceSummary: parsed.lastMaintenanceSummary,
|
|
155
171
|
};
|
|
172
|
+
for (const collection of COLLECTIONS) {
|
|
173
|
+
const raw = this.store.getString(keyFor(collection));
|
|
174
|
+
const entries: unknown = raw === undefined ? [] : JSON.parse(raw);
|
|
175
|
+
if (!Array.isArray(entries)) {
|
|
176
|
+
return this.discardAndRebuild('unparseable', { collection });
|
|
177
|
+
}
|
|
178
|
+
record[collection] = entries;
|
|
179
|
+
}
|
|
180
|
+
return record as unknown as StateRecord;
|
|
156
181
|
} catch {
|
|
157
182
|
return this.discardAndRebuild('unparseable');
|
|
158
183
|
}
|
|
159
184
|
}
|
|
160
185
|
|
|
186
|
+
/** Reads the pre-0.6.0 single record, writes it out per collection, and drops it. */
|
|
187
|
+
private migrateLegacy(): StateRecord {
|
|
188
|
+
const raw = this.store.getString(STATE_KEY);
|
|
189
|
+
if (raw === undefined) {
|
|
190
|
+
const fresh = emptyRecord();
|
|
191
|
+
this.persistMeta(fresh);
|
|
192
|
+
return fresh;
|
|
193
|
+
}
|
|
194
|
+
const parsed = JSON.parse(raw) as Partial<StateRecord>;
|
|
195
|
+
if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
|
|
196
|
+
return this.discardAndRebuild('schema-version-mismatch', {
|
|
197
|
+
found: parsed.schemaVersion,
|
|
198
|
+
expected: STATE_SCHEMA_VERSION,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
this.record = {
|
|
202
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
203
|
+
dedupLedger: parsed.dedupLedger ?? [],
|
|
204
|
+
interactionBuffer: parsed.interactionBuffer ?? [],
|
|
205
|
+
migrationLedger: parsed.migrationLedger ?? [],
|
|
206
|
+
seriesLedger: parsed.seriesLedger ?? [],
|
|
207
|
+
permissionHistory: parsed.permissionHistory ?? [],
|
|
208
|
+
lastReconcileSummary: parsed.lastReconcileSummary,
|
|
209
|
+
lastMaintenanceSummary: parsed.lastMaintenanceSummary,
|
|
210
|
+
};
|
|
211
|
+
for (const collection of COLLECTIONS) {
|
|
212
|
+
this.persist(collection);
|
|
213
|
+
}
|
|
214
|
+
this.persistMeta(this.record);
|
|
215
|
+
this.store.delete(STATE_KEY);
|
|
216
|
+
return this.record;
|
|
217
|
+
}
|
|
218
|
+
|
|
161
219
|
private discardAndRebuild(
|
|
162
220
|
reason: string,
|
|
163
221
|
extra?: Readonly<Record<string, unknown>>,
|
|
164
222
|
): StateRecord {
|
|
165
223
|
this.rebuilt = true;
|
|
166
|
-
this.
|
|
224
|
+
this.deleteAll();
|
|
225
|
+
this.persistMeta(emptyRecord());
|
|
167
226
|
this.emit({
|
|
168
227
|
code: DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT,
|
|
169
228
|
// Note what is actually lost, so the consequence is stated rather than implied.
|
|
@@ -172,10 +231,18 @@ export class StateManager {
|
|
|
172
231
|
return emptyRecord();
|
|
173
232
|
}
|
|
174
233
|
|
|
234
|
+
private deleteAll(): void {
|
|
235
|
+
this.store.delete(STATE_KEY);
|
|
236
|
+
this.store.delete(META_KEY);
|
|
237
|
+
for (const collection of COLLECTIONS) {
|
|
238
|
+
this.store.delete(keyFor(collection));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
175
242
|
/** Never throws. A storage failure degrades the affected guarantee, nothing more. */
|
|
176
|
-
private
|
|
243
|
+
private write(key: string, value: unknown): void {
|
|
177
244
|
try {
|
|
178
|
-
this.store.setString(
|
|
245
|
+
this.store.setString(key, JSON.stringify(value));
|
|
179
246
|
} catch (error) {
|
|
180
247
|
this.emit({
|
|
181
248
|
code: DIAGNOSTIC_CODES.STATE_WRITE_FAILED,
|
|
@@ -184,6 +251,20 @@ export class StateManager {
|
|
|
184
251
|
}
|
|
185
252
|
}
|
|
186
253
|
|
|
254
|
+
/** Writes one collection, and only that one. */
|
|
255
|
+
private persist(collection: CollectionName): void {
|
|
256
|
+
this.write(keyFor(collection), this.record[collection]);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The schema version and summaries. Present whenever any collection is stored. */
|
|
260
|
+
private persistMeta(record: StateRecord = this.record): void {
|
|
261
|
+
this.write(META_KEY, {
|
|
262
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
263
|
+
lastReconcileSummary: record.lastReconcileSummary,
|
|
264
|
+
lastMaintenanceSummary: record.lastMaintenanceSummary,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
187
268
|
read(): StateRecord {
|
|
188
269
|
return this.record;
|
|
189
270
|
}
|
|
@@ -194,7 +275,17 @@ export class StateManager {
|
|
|
194
275
|
mutate: (current: StateRecord[K]) => StateRecord[K],
|
|
195
276
|
): void {
|
|
196
277
|
const next = mutate(this.record[collection]);
|
|
197
|
-
const { kept, evicted, reason } = evict(
|
|
278
|
+
const { kept, evicted, reason, overCap } = evict(
|
|
279
|
+
next as readonly Timestamped[],
|
|
280
|
+
collection,
|
|
281
|
+
this.now(),
|
|
282
|
+
);
|
|
283
|
+
if (overCap > 0) {
|
|
284
|
+
this.emit({
|
|
285
|
+
code: DIAGNOSTIC_CODES.STATE_CAP_EXCEEDED,
|
|
286
|
+
context: { collection, overCap, cap: COLLECTION_CAPS[collection].maxEntries },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
198
289
|
|
|
199
290
|
if (evicted.length > 0) {
|
|
200
291
|
this.emit({
|
|
@@ -207,7 +298,7 @@ export class StateManager {
|
|
|
207
298
|
}
|
|
208
299
|
|
|
209
300
|
this.record = { ...this.record, [collection]: kept } as StateRecord;
|
|
210
|
-
this.persist();
|
|
301
|
+
this.persist(collection);
|
|
211
302
|
}
|
|
212
303
|
|
|
213
304
|
setSummary(
|
|
@@ -215,14 +306,15 @@ export class StateManager {
|
|
|
215
306
|
value: Readonly<Record<string, unknown>>,
|
|
216
307
|
): void {
|
|
217
308
|
this.record = { ...this.record, [key]: value };
|
|
218
|
-
this.
|
|
309
|
+
this.persistMeta();
|
|
219
310
|
}
|
|
220
311
|
|
|
221
312
|
/** Clears everything. Public through `resetState()`. */
|
|
222
313
|
reset(): void {
|
|
223
314
|
this.record = emptyRecord();
|
|
224
315
|
this.rebuilt = false;
|
|
225
|
-
this.
|
|
316
|
+
this.deleteAll();
|
|
317
|
+
this.persistMeta();
|
|
226
318
|
}
|
|
227
319
|
|
|
228
320
|
/**
|
|
@@ -240,7 +332,9 @@ export class StateManager {
|
|
|
240
332
|
e => !e.key.startsWith(`${ownerTag}:`),
|
|
241
333
|
),
|
|
242
334
|
};
|
|
243
|
-
this.persist();
|
|
335
|
+
this.persist('dedupLedger');
|
|
336
|
+
this.persist('interactionBuffer');
|
|
337
|
+
this.persist('permissionHistory');
|
|
244
338
|
}
|
|
245
339
|
|
|
246
340
|
health(): StateHealth {
|
package/src/state/store.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface StateStore {
|
|
|
13
13
|
clearAll(): void;
|
|
14
14
|
/** Approximate on-disk size, for the health report. */
|
|
15
15
|
approximateBytes(): number;
|
|
16
|
+
/** False when writes do not survive a restart (the memory fallback). */
|
|
17
|
+
readonly durable?: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** In-memory store, used by tests and as the fallback when MMKV is unavailable. */
|
|
@@ -36,5 +38,6 @@ export function createMemoryStore(): StateStore {
|
|
|
36
38
|
}
|
|
37
39
|
return total;
|
|
38
40
|
},
|
|
41
|
+
durable: false,
|
|
39
42
|
};
|
|
40
43
|
}
|
|
@@ -83,6 +83,11 @@ export interface FakeControls {
|
|
|
83
83
|
|
|
84
84
|
/** Queues an interaction that arrived before any handler existed. */
|
|
85
85
|
emitInteraction(event: InteractionEvent): void;
|
|
86
|
+
/**
|
|
87
|
+
* An interaction while the app is not in front: live `subscribeToInteractions`
|
|
88
|
+
* handlers get it, otherwise the registered background handler does.
|
|
89
|
+
*/
|
|
90
|
+
emitBackgroundInteraction(event: InteractionEvent): Promise<void>;
|
|
86
91
|
queueInitialInteraction(event: InteractionEvent): void;
|
|
87
92
|
|
|
88
93
|
/** The labels a declared channel currently carries, so a label refresh is assertable. */
|
|
@@ -119,6 +124,7 @@ export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
|
|
|
119
124
|
let listeners: ((event: InteractionEvent) => void)[] = [];
|
|
120
125
|
let deliveryListeners: ((event: DeliveryEvent) => void)[] = [];
|
|
121
126
|
let backgroundHandler: ((event: DeliveryEvent) => Promise<void>) | undefined;
|
|
127
|
+
let backgroundInteractionHandler: ((event: InteractionEvent) => Promise<void>) | undefined;
|
|
122
128
|
let initialInteraction: InteractionEvent | undefined;
|
|
123
129
|
|
|
124
130
|
let currentTime = options.now ?? Date.UTC(2026, 0, 1, 12, 0, 0);
|
|
@@ -313,8 +319,9 @@ export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
|
|
|
313
319
|
};
|
|
314
320
|
},
|
|
315
321
|
|
|
316
|
-
registerBackgroundDeliveryHandler(handler) {
|
|
322
|
+
registerBackgroundDeliveryHandler(handler, onInteraction) {
|
|
317
323
|
backgroundHandler = handler;
|
|
324
|
+
backgroundInteractionHandler = onInteraction;
|
|
318
325
|
},
|
|
319
326
|
|
|
320
327
|
hasBackgroundDeliveryHandler() {
|
|
@@ -385,6 +392,15 @@ export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
|
|
|
385
392
|
}
|
|
386
393
|
},
|
|
387
394
|
|
|
395
|
+
async emitBackgroundInteraction(event) {
|
|
396
|
+
const background: InteractionEvent = { ...event, appState: 'background' };
|
|
397
|
+
if (listeners.length > 0) {
|
|
398
|
+
for (const listener of [...listeners]) listener(background);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
await backgroundInteractionHandler?.(background);
|
|
402
|
+
},
|
|
403
|
+
|
|
388
404
|
emitInteraction(event) {
|
|
389
405
|
for (const listener of listeners) {
|
|
390
406
|
listener(event);
|