@chipmobilesdk/rn-notification 0.1.1 → 0.2.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 +80 -42
- package/README.md +399 -306
- package/package.json +2 -1
- package/skills/sdk-rn-notification/SKILL.md +68 -3
- package/src/channel/migrate.ts +86 -12
- package/src/channel/tone-map.ts +130 -90
- package/src/channel/validate-tone.ts +61 -0
- package/src/contract/channel.ts +188 -89
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +130 -123
- package/src/contract/index.ts +10 -10
- package/src/contract/interaction.ts +62 -62
- package/src/contract/notification.ts +118 -118
- package/src/contract/permission.ts +60 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +123 -123
- package/src/diagnostics/burst.ts +101 -101
- package/src/diagnostics/events.ts +72 -72
- package/src/diagnostics/redact.ts +72 -72
- package/src/diagnostics/snapshot.ts +64 -64
- package/src/engine/adapter.ts +172 -79
- package/src/engine/notify-kit-engine.ts +761 -535
- package/src/index.ts +35 -0
- package/src/initialize.ts +174 -174
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +147 -147
- package/src/maintenance/permission-refresh.ts +41 -41
- package/src/maintenance/reanchor.ts +95 -95
- package/src/maintenance/toggle.ts +60 -60
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +61 -61
- package/src/permission/index.ts +183 -183
- package/src/permission/state.ts +77 -77
- package/src/push/envelope.ts +165 -165
- package/src/push/fcm-provider.ts +146 -146
- package/src/push/gate.ts +190 -190
- package/src/push/instructions.ts +109 -109
- package/src/push/provider-adapter.ts +54 -54
- package/src/push/signout.ts +65 -65
- package/src/reconcile/chunk.ts +53 -53
- package/src/reconcile/diff.ts +145 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +14 -1
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +71 -71
- package/src/recurrence/expand.ts +292 -292
- package/src/repeat/expiry.ts +55 -0
- package/src/repeat/index.ts +54 -0
- package/src/repeat/marker.ts +72 -0
- package/src/repeat/place.ts +73 -0
- package/src/schedule/anchor.ts +267 -267
- package/src/schedule/index.ts +184 -173
- package/src/schedule/partition.ts +85 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -114
- package/src/state/caps.ts +71 -71
- package/src/state/dedup-ledger.ts +35 -39
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +71 -71
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +258 -246
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +84 -84
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +248 -194
- package/src/testing/fake-engine.ts +469 -284
- package/src/testing/fake-provider.ts +129 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/index.ts +2 -0
- package/src/testing/measure.ts +337 -0
|
@@ -1,284 +1,469 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory engine for tests.
|
|
3
|
-
*
|
|
4
|
-
* Exists so every decision path is provable with no device, no emulator, and
|
|
5
|
-
* no live provider. Its controls simulate the conditions that are otherwise
|
|
6
|
-
* only reachable on hardware: virtual time, time-zone travel, permission
|
|
7
|
-
* transitions, process death, boot-restoration gaps, and platform truncation.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type {
|
|
11
|
-
NotificationEngineAdapter,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import type {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
readonly
|
|
26
|
-
readonly
|
|
27
|
-
readonly
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
readonly
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
},
|
|
197
|
-
|
|
198
|
-
async
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* In-memory engine for tests.
|
|
3
|
+
*
|
|
4
|
+
* Exists so every decision path is provable with no device, no emulator, and
|
|
5
|
+
* no live provider. Its controls simulate the conditions that are otherwise
|
|
6
|
+
* only reachable on hardware: virtual time, time-zone travel, permission
|
|
7
|
+
* transitions, process death, boot-restoration gaps, and platform truncation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
NotificationEngineAdapter,
|
|
12
|
+
DeliveryEvent,
|
|
13
|
+
EngineCapabilities,
|
|
14
|
+
EngineScheduleRequest,
|
|
15
|
+
RepeatStop,
|
|
16
|
+
ToneDeclarationOutcome,
|
|
17
|
+
} from '../engine/adapter';
|
|
18
|
+
import type { PendingEntry } from '../contract/notification';
|
|
19
|
+
import type { PermissionKind, PermissionState, RestrictionReport } from '../contract/permission';
|
|
20
|
+
import type { ResolvedTone, ChannelGroup } from '../contract/channel';
|
|
21
|
+
import type { InteractionEvent, Unsubscribe } from '../contract/interaction';
|
|
22
|
+
import { isRepeatStopId } from '../repeat/marker';
|
|
23
|
+
|
|
24
|
+
export interface FakeEngineOptions {
|
|
25
|
+
readonly pendingCeiling?: number;
|
|
26
|
+
readonly supportsExactAlarms?: boolean;
|
|
27
|
+
readonly supportsDoNotDisturbBypass?: boolean;
|
|
28
|
+
readonly supportsRepeatingAlert?: boolean;
|
|
29
|
+
readonly backgroundWindowMs?: number;
|
|
30
|
+
readonly initialPermissions?: Partial<Record<PermissionKind, PermissionState>>;
|
|
31
|
+
readonly timeZone?: string;
|
|
32
|
+
readonly now?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every platform operation the engine was asked to perform, in order. */
|
|
36
|
+
export interface RecordedOperation {
|
|
37
|
+
readonly kind:
|
|
38
|
+
| 'schedule'
|
|
39
|
+
| 'cancel'
|
|
40
|
+
| 'cancelAll'
|
|
41
|
+
| 'declareTone'
|
|
42
|
+
| 'retireTone'
|
|
43
|
+
| 'silence';
|
|
44
|
+
readonly id?: string;
|
|
45
|
+
readonly at: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A notification the fake has delivered to the tray. */
|
|
49
|
+
export interface DisplayedNotification {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly tone: string;
|
|
52
|
+
/** Whether its alert is still sounding — the property repeat expiry is about. */
|
|
53
|
+
readonly alerting: boolean;
|
|
54
|
+
readonly deliveredAt: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FakeControls {
|
|
58
|
+
/** Operations recorded since the last reset. The idempotence assertion reads this. */
|
|
59
|
+
readonly operations: readonly RecordedOperation[];
|
|
60
|
+
clearOperations(): void;
|
|
61
|
+
|
|
62
|
+
/** Virtual clock, so tests never sleep. */
|
|
63
|
+
now(): number;
|
|
64
|
+
advanceTime(ms: number): void;
|
|
65
|
+
setTime(epochMs: number): void;
|
|
66
|
+
|
|
67
|
+
/** Simulates the user travelling. */
|
|
68
|
+
timeZone(): string;
|
|
69
|
+
setTimeZone(zone: string): void;
|
|
70
|
+
|
|
71
|
+
setPermission(kind: PermissionKind, state: PermissionState): void;
|
|
72
|
+
setRestrictions(report: Partial<RestrictionReport>): void;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Drops entries the platform would have silently discarded past its ceiling,
|
|
76
|
+
* so truncation behavior is testable without an iOS device.
|
|
77
|
+
*/
|
|
78
|
+
applyPlatformCeiling(): readonly string[];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Simulates the process dying: in-memory listeners are lost while scheduled
|
|
82
|
+
* notifications survive, which is exactly what happens on a real device.
|
|
83
|
+
*/
|
|
84
|
+
simulateProcessDeath(): void;
|
|
85
|
+
|
|
86
|
+
/** Simulates a manufacturer losing notifications across a reboot. */
|
|
87
|
+
simulateBootGap(ids: readonly string[]): void;
|
|
88
|
+
|
|
89
|
+
/** Queues an interaction that arrived before any handler existed. */
|
|
90
|
+
emitInteraction(event: InteractionEvent): void;
|
|
91
|
+
queueInitialInteraction(event: InteractionEvent): void;
|
|
92
|
+
|
|
93
|
+
/** The labels a declared channel currently carries, so a label refresh is assertable. */
|
|
94
|
+
channelLabels(channelId: string): { name: string; description?: string } | undefined;
|
|
95
|
+
|
|
96
|
+
/** Whether a delivered notification's alert is still sounding. */
|
|
97
|
+
isAlerting(id: string): boolean;
|
|
98
|
+
|
|
99
|
+
/** The tray. Distinct from the pending set: expiry is about what is *displayed*. */
|
|
100
|
+
displayed(): readonly DisplayedNotification[];
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fires every trigger whose instant has passed, in order, on the virtual clock.
|
|
104
|
+
*
|
|
105
|
+
* The fake modelled time, process death, and boot gaps but never modelled
|
|
106
|
+
* delivery — and expiry is a delivery-ordering property. Without this, proving
|
|
107
|
+
* that an alert stops on its own would need a real device or a real wait.
|
|
108
|
+
*/
|
|
109
|
+
deliverDue(): Promise<readonly string[]>;
|
|
110
|
+
|
|
111
|
+
/** Simulates the user acting on a delivered notification: the alert stops, the entry goes. */
|
|
112
|
+
handleDisplayed(id: string): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type FakeEngine = NotificationEngineAdapter & FakeControls;
|
|
116
|
+
|
|
117
|
+
export function createFakeEngine(options: FakeEngineOptions = {}): FakeEngine {
|
|
118
|
+
const scheduled = new Map<string, PendingEntry>();
|
|
119
|
+
const channels = new Map<string, { name: string; description?: string }>();
|
|
120
|
+
const loops = new Map<string, boolean>();
|
|
121
|
+
const stops = new Map<string, RepeatStop>();
|
|
122
|
+
const tray = new Map<string, { tone: string; alerting: boolean; deliveredAt: number }>();
|
|
123
|
+
let operations: RecordedOperation[] = [];
|
|
124
|
+
let listeners: ((event: InteractionEvent) => void)[] = [];
|
|
125
|
+
let deliveryListeners: ((event: DeliveryEvent) => void)[] = [];
|
|
126
|
+
let backgroundHandler: ((event: DeliveryEvent) => Promise<void>) | undefined;
|
|
127
|
+
let initialInteraction: InteractionEvent | undefined;
|
|
128
|
+
|
|
129
|
+
let currentTime = options.now ?? Date.UTC(2026, 0, 1, 12, 0, 0);
|
|
130
|
+
let currentZone = options.timeZone ?? 'UTC';
|
|
131
|
+
|
|
132
|
+
const permissions: Record<PermissionKind, PermissionState> = {
|
|
133
|
+
display: options.initialPermissions?.display ?? 'notAsked',
|
|
134
|
+
exactAlarm: options.initialPermissions?.exactAlarm ?? 'notAsked',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
let restrictions: RestrictionReport = {
|
|
138
|
+
batteryOptimization: 'unrestricted',
|
|
139
|
+
autoStart: 'unrestricted',
|
|
140
|
+
readAt: currentTime,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const capabilities: EngineCapabilities = {
|
|
144
|
+
pendingCeiling: options.pendingCeiling ?? 64,
|
|
145
|
+
supportsExactAlarms: options.supportsExactAlarms ?? true,
|
|
146
|
+
supportsChannels: true,
|
|
147
|
+
supportsTextInputActions: true,
|
|
148
|
+
supportsDoNotDisturbBypass: options.supportsDoNotDisturbBypass ?? true,
|
|
149
|
+
supportsRepeatingAlert: options.supportsRepeatingAlert ?? true,
|
|
150
|
+
backgroundWindowMs: options.backgroundWindowMs ?? 30_000,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const record = (kind: RecordedOperation['kind'], id?: string): void => {
|
|
154
|
+
operations.push({ kind, id, at: currentTime });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const toPendingEntry = (request: EngineScheduleRequest): PendingEntry => ({
|
|
158
|
+
id: request.request.id,
|
|
159
|
+
resolvedAt: request.resolvedAt,
|
|
160
|
+
anchoring: request.request.moment.anchoring ?? 'wallClock',
|
|
161
|
+
anchoringGeneration:
|
|
162
|
+
(request.request.moment.anchoring ?? 'wallClock') === 'wallClock' ? currentZone : undefined,
|
|
163
|
+
content: request.request.content,
|
|
164
|
+
tone: request.request.tone,
|
|
165
|
+
routing: request.request.routing,
|
|
166
|
+
actions: request.request.actions,
|
|
167
|
+
ownerTag: request.request.ownerTag,
|
|
168
|
+
groupTag: request.request.groupTag,
|
|
169
|
+
seriesId: request.seriesId,
|
|
170
|
+
mayBeDelayed: request.approximate,
|
|
171
|
+
pushCreated: request.pushCreated ?? false,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
name: 'fake',
|
|
176
|
+
|
|
177
|
+
async getCapabilities() {
|
|
178
|
+
return capabilities;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async schedule(request) {
|
|
182
|
+
record('schedule', request.request.id);
|
|
183
|
+
scheduled.set(request.request.id, toPendingEntry(request));
|
|
184
|
+
loops.set(request.request.id, request.tone.android.loopSound);
|
|
185
|
+
if (request.repeatStop !== undefined) {
|
|
186
|
+
stops.set(request.request.id, request.repeatStop);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async cancel(id) {
|
|
191
|
+
record('cancel', id);
|
|
192
|
+
scheduled.delete(id);
|
|
193
|
+
loops.delete(id);
|
|
194
|
+
stops.delete(id);
|
|
195
|
+
tray.delete(id);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async cancelMany(ids) {
|
|
199
|
+
for (const id of ids) {
|
|
200
|
+
record('cancel', id);
|
|
201
|
+
scheduled.delete(id);
|
|
202
|
+
loops.delete(id);
|
|
203
|
+
stops.delete(id);
|
|
204
|
+
tray.delete(id);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
async cancelAll() {
|
|
209
|
+
record('cancelAll');
|
|
210
|
+
scheduled.clear();
|
|
211
|
+
loops.clear();
|
|
212
|
+
stops.clear();
|
|
213
|
+
tray.clear();
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async getPending() {
|
|
217
|
+
// Stop markers are package-owned artifacts, not user notifications. A
|
|
218
|
+
// consumer diffing the device against its own intent must never see one.
|
|
219
|
+
return [...scheduled.values()]
|
|
220
|
+
.filter(entry => !isRepeatStopId(entry.id))
|
|
221
|
+
.sort((a, b) => a.resolvedAt - b.resolvedAt);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async declareTone(tone: ResolvedTone): Promise<ToneDeclarationOutcome> {
|
|
225
|
+
record('declareTone', tone.android.channelId);
|
|
226
|
+
const existing = channels.get(tone.android.channelId);
|
|
227
|
+
|
|
228
|
+
if (existing === undefined) {
|
|
229
|
+
channels.set(tone.android.channelId, {
|
|
230
|
+
name: tone.android.name,
|
|
231
|
+
description: tone.android.description,
|
|
232
|
+
});
|
|
233
|
+
return 'created';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Android freezes importance, sound, vibration and DND once a channel
|
|
237
|
+
// exists, but re-declaring updates the name and description. The fake
|
|
238
|
+
// mirrors exactly that: labels move, everything else is ignored — which
|
|
239
|
+
// is what makes a label correction free for the user.
|
|
240
|
+
if (
|
|
241
|
+
existing.name === tone.android.name &&
|
|
242
|
+
existing.description === tone.android.description
|
|
243
|
+
) {
|
|
244
|
+
return 'unchanged';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
channels.set(tone.android.channelId, {
|
|
248
|
+
name: tone.android.name,
|
|
249
|
+
description: tone.android.description,
|
|
250
|
+
});
|
|
251
|
+
return 'labelsRefreshed';
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async retireTone(channelId: string) {
|
|
255
|
+
record('retireTone', channelId);
|
|
256
|
+
return channels.delete(channelId);
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async declareGroups(_groups: readonly ChannelGroup[]) {
|
|
260
|
+
// Groups are presentational; the fake accepts them without recording.
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async listDeclaredChannels() {
|
|
264
|
+
return [...channels.keys()];
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
async getPermission(kind) {
|
|
268
|
+
return permissions[kind];
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async requestPermission(kind) {
|
|
272
|
+
if (permissions[kind] === 'notAsked') {
|
|
273
|
+
permissions[kind] = 'granted';
|
|
274
|
+
}
|
|
275
|
+
return permissions[kind];
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async openSettings(_kind) {
|
|
279
|
+
// No-op: the fake cannot open a settings screen, and pretending it did would be a lie.
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
async getRestrictions() {
|
|
283
|
+
return { ...restrictions, readAt: currentTime };
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
subscribeToInteractions(handler): Unsubscribe {
|
|
287
|
+
listeners.push(handler);
|
|
288
|
+
return () => {
|
|
289
|
+
listeners = listeners.filter(l => l !== handler);
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async takeInitialInteraction() {
|
|
294
|
+
const event = initialInteraction;
|
|
295
|
+
initialInteraction = undefined;
|
|
296
|
+
return event;
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async silenceDelivered(id: string) {
|
|
300
|
+
record('silence', id);
|
|
301
|
+
const entry = tray.get(id);
|
|
302
|
+
// Absent is the ordinary case, not an error: the user handled it before
|
|
303
|
+
// the window ran out.
|
|
304
|
+
if (entry === undefined) return false;
|
|
305
|
+
|
|
306
|
+
// Replaced in place, never removed. A notification that vanished is a
|
|
307
|
+
// worse outcome than one that never sounded.
|
|
308
|
+
tray.set(id, { ...entry, alerting: false });
|
|
309
|
+
return true;
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async listRepeatStops() {
|
|
313
|
+
return [...stops.values()].filter(stop => scheduled.has(stop.markerId));
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
subscribeToDeliveries(handler): Unsubscribe {
|
|
317
|
+
deliveryListeners.push(handler);
|
|
318
|
+
return () => {
|
|
319
|
+
deliveryListeners = deliveryListeners.filter(l => l !== handler);
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
registerBackgroundDeliveryHandler(handler) {
|
|
324
|
+
backgroundHandler = handler;
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
hasBackgroundDeliveryHandler() {
|
|
328
|
+
return backgroundHandler !== undefined;
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
// --- FakeControls ------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
get operations() {
|
|
334
|
+
return operations;
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
clearOperations() {
|
|
338
|
+
operations = [];
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
now() {
|
|
342
|
+
return currentTime;
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
advanceTime(ms) {
|
|
346
|
+
currentTime += ms;
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
setTime(epochMs) {
|
|
350
|
+
currentTime = epochMs;
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
timeZone() {
|
|
354
|
+
return currentZone;
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
setTimeZone(zone) {
|
|
358
|
+
currentZone = zone;
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
setPermission(kind, state) {
|
|
362
|
+
permissions[kind] = state;
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
setRestrictions(report) {
|
|
366
|
+
restrictions = { ...restrictions, ...report };
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
applyPlatformCeiling() {
|
|
370
|
+
const ordered = [...scheduled.values()].sort((a, b) => a.resolvedAt - b.resolvedAt);
|
|
371
|
+
const dropped = ordered.slice(capabilities.pendingCeiling);
|
|
372
|
+
for (const entry of dropped) {
|
|
373
|
+
scheduled.delete(entry.id);
|
|
374
|
+
}
|
|
375
|
+
return dropped.map(entry => entry.id);
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
simulateProcessDeath() {
|
|
379
|
+
// Scheduled notifications live in the OS and survive; in-process
|
|
380
|
+
// listeners do not. The background handler does survive, because on a
|
|
381
|
+
// real device it is registered at module scope and the platform starts
|
|
382
|
+
// a fresh process to run it — which is the entire reason repeat expiry
|
|
383
|
+
// depends on it rather than on a foreground subscription.
|
|
384
|
+
listeners = [];
|
|
385
|
+
deliveryListeners = [];
|
|
386
|
+
operations = [];
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
simulateBootGap(ids) {
|
|
390
|
+
for (const id of ids) {
|
|
391
|
+
scheduled.delete(id);
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
emitInteraction(event) {
|
|
396
|
+
for (const listener of listeners) {
|
|
397
|
+
listener(event);
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
queueInitialInteraction(event) {
|
|
402
|
+
initialInteraction = event;
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
channelLabels(channelId) {
|
|
406
|
+
return channels.get(channelId);
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
isAlerting(id) {
|
|
410
|
+
return tray.get(id)?.alerting ?? false;
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
displayed() {
|
|
414
|
+
return [...tray.entries()].map(([id, entry]) => ({
|
|
415
|
+
id,
|
|
416
|
+
tone: entry.tone,
|
|
417
|
+
alerting: entry.alerting,
|
|
418
|
+
deliveredAt: entry.deliveredAt,
|
|
419
|
+
}));
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
async deliverDue() {
|
|
423
|
+
const due = [...scheduled.values()]
|
|
424
|
+
.filter(entry => entry.resolvedAt <= currentTime)
|
|
425
|
+
.sort((a, b) => a.resolvedAt - b.resolvedAt);
|
|
426
|
+
|
|
427
|
+
const delivered: string[] = [];
|
|
428
|
+
|
|
429
|
+
for (const entry of due) {
|
|
430
|
+
scheduled.delete(entry.id);
|
|
431
|
+
const stop = stops.get(entry.id);
|
|
432
|
+
|
|
433
|
+
if (stop === undefined) {
|
|
434
|
+
// A real notification: it lands in the tray and, if its tone
|
|
435
|
+
// repeats, keeps sounding until something stops it.
|
|
436
|
+
tray.set(entry.id, {
|
|
437
|
+
tone: entry.tone,
|
|
438
|
+
alerting: loops.get(entry.id) ?? false,
|
|
439
|
+
deliveredAt: currentTime,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const event: DeliveryEvent = {
|
|
444
|
+
notificationId: entry.id,
|
|
445
|
+
occurredAt: currentTime,
|
|
446
|
+
repeatStop: stop,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Foreground listeners when the process is alive, the background
|
|
450
|
+
// handler when it is not — the same fork a real device makes.
|
|
451
|
+
if (deliveryListeners.length > 0) {
|
|
452
|
+
for (const listener of deliveryListeners) {
|
|
453
|
+
listener(event);
|
|
454
|
+
}
|
|
455
|
+
} else if (backgroundHandler !== undefined) {
|
|
456
|
+
await backgroundHandler(event);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
delivered.push(entry.id);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return delivered;
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
handleDisplayed(id) {
|
|
466
|
+
tray.delete(id);
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|