react-native-qpush 0.0.0-stage → 1.0.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.
Files changed (98) hide show
  1. package/README.md +439 -2
  2. package/android/build.gradle +77 -0
  3. package/android/gradle.properties +5 -0
  4. package/android/proguard-rules.pro +5 -0
  5. package/android/src/main/AndroidManifest.xml +29 -0
  6. package/android/src/main/java/com/qpush/reactnative/QPushDeviceInfo.kt +96 -0
  7. package/android/src/main/java/com/qpush/reactnative/QPushEventBus.kt +153 -0
  8. package/android/src/main/java/com/qpush/reactnative/QPushFcm.kt +108 -0
  9. package/android/src/main/java/com/qpush/reactnative/QPushHttp.kt +92 -0
  10. package/android/src/main/java/com/qpush/reactnative/QPushImages.kt +103 -0
  11. package/android/src/main/java/com/qpush/reactnative/QPushLog.kt +39 -0
  12. package/android/src/main/java/com/qpush/reactnative/QPushMessagingService.kt +77 -0
  13. package/android/src/main/java/com/qpush/reactnative/QPushNotificationOpenActivity.kt +121 -0
  14. package/android/src/main/java/com/qpush/reactnative/QPushNotificationRenderer.kt +256 -0
  15. package/android/src/main/java/com/qpush/reactnative/QPushPayload.kt +114 -0
  16. package/android/src/main/java/com/qpush/reactnative/QPushPushModule.kt +368 -0
  17. package/android/src/main/java/com/qpush/reactnative/QPushPushPackage.kt +15 -0
  18. package/android/src/main/java/com/qpush/reactnative/QPushStore.kt +108 -0
  19. package/android/src/main/res/values/qpush_strings.xml +6 -0
  20. package/app.plugin.js +1 -0
  21. package/ios/Extension/QPushNotificationServiceExtension.swift +261 -0
  22. package/ios/Extension/QPushPushExtension.podspec +34 -0
  23. package/ios/QPushPush.m +62 -0
  24. package/ios/QPushPush.swift +186 -0
  25. package/ios/QPushPushBridge.h +5 -0
  26. package/ios/QPushPushCore.swift +457 -0
  27. package/ios/QPushPushHooks.m +237 -0
  28. package/ios/QPushPushSupport.swift +249 -0
  29. package/ios_nse_template/Info.plist +38 -0
  30. package/ios_nse_template/NotificationService.entitlements +10 -0
  31. package/ios_nse_template/NotificationService.swift +44 -0
  32. package/lib/NativeQPushPush.d.ts +44 -0
  33. package/lib/NativeQPushPush.js +4 -0
  34. package/lib/QPush.d.ts +117 -0
  35. package/lib/QPush.js +206 -0
  36. package/lib/core/QPushCore.d.ts +148 -0
  37. package/lib/core/QPushCore.js +1387 -0
  38. package/lib/core/constants.d.ts +37 -0
  39. package/lib/core/constants.js +40 -0
  40. package/lib/core/events.d.ts +11 -0
  41. package/lib/core/events.js +40 -0
  42. package/lib/core/http.d.ts +41 -0
  43. package/lib/core/http.js +123 -0
  44. package/lib/core/logger.d.ts +18 -0
  45. package/lib/core/logger.js +43 -0
  46. package/lib/core/operationQueue.d.ts +117 -0
  47. package/lib/core/operationQueue.js +264 -0
  48. package/lib/core/operations.d.ts +80 -0
  49. package/lib/core/operations.js +88 -0
  50. package/lib/core/payload.d.ts +20 -0
  51. package/lib/core/payload.js +187 -0
  52. package/lib/core/platform.d.ts +72 -0
  53. package/lib/core/platform.js +7 -0
  54. package/lib/core/state.d.ts +51 -0
  55. package/lib/core/state.js +74 -0
  56. package/lib/core/storage.d.ts +34 -0
  57. package/lib/core/storage.js +68 -0
  58. package/lib/core/subscription.d.ts +67 -0
  59. package/lib/core/subscription.js +98 -0
  60. package/lib/core/tags.d.ts +39 -0
  61. package/lib/core/tags.js +123 -0
  62. package/lib/core/url.d.ts +17 -0
  63. package/lib/core/url.js +64 -0
  64. package/lib/core/uuid.d.ts +2 -0
  65. package/lib/core/uuid.js +18 -0
  66. package/lib/index.d.ts +7 -0
  67. package/lib/index.js +15 -0
  68. package/lib/notification.d.ts +67 -0
  69. package/lib/notification.js +73 -0
  70. package/lib/reactNativePlatform.d.ts +3 -0
  71. package/lib/reactNativePlatform.js +164 -0
  72. package/lib/types.d.ts +157 -0
  73. package/lib/types.js +31 -0
  74. package/package.json +70 -4
  75. package/plugin/withQPush.js +330 -0
  76. package/react-native-qpush.podspec +30 -0
  77. package/react-native.config.js +18 -0
  78. package/src/NativeQPushPush.ts +57 -0
  79. package/src/QPush.ts +292 -0
  80. package/src/core/QPushCore.ts +1515 -0
  81. package/src/core/constants.ts +42 -0
  82. package/src/core/events.ts +34 -0
  83. package/src/core/http.ts +146 -0
  84. package/src/core/logger.ts +42 -0
  85. package/src/core/operationQueue.ts +324 -0
  86. package/src/core/operations.ts +166 -0
  87. package/src/core/payload.ts +175 -0
  88. package/src/core/platform.ts +84 -0
  89. package/src/core/state.ts +127 -0
  90. package/src/core/storage.ts +76 -0
  91. package/src/core/subscription.ts +133 -0
  92. package/src/core/tags.ts +116 -0
  93. package/src/core/url.ts +58 -0
  94. package/src/core/uuid.ts +13 -0
  95. package/src/index.ts +23 -0
  96. package/src/notification.ts +113 -0
  97. package/src/reactNativePlatform.ts +172 -0
  98. package/src/types.ts +174 -0
@@ -0,0 +1,1515 @@
1
+ import {
2
+ QPushNotification,
3
+ NotificationWillDisplayEvent,
4
+ type NotificationClickEvent,
5
+ } from '../notification';
6
+ import {
7
+ LogLevel,
8
+ NotificationPermission,
9
+ type AndroidParams,
10
+ type QPushInitOptions,
11
+ QPUSH_CLOUD_API_URL,
12
+ type PermissionStatus,
13
+ type PushSubscriptionChangedState,
14
+ type PushSubscriptionState,
15
+ type SubscriptionPayload,
16
+ type UserChangedState,
17
+ type UserPayload,
18
+ type UserState,
19
+ } from '../types';
20
+ import {
21
+ BACKGROUND_FLUSH_TIMEOUT_MS,
22
+ MAX_PENDING_CLICKS,
23
+ NativeEventNames,
24
+ NativeKeys,
25
+ PUSH_REGISTRATION_TIMEOUT_MS,
26
+ SESSION_TIMEOUT_MS,
27
+ StorageKeys,
28
+ } from './constants';
29
+ import { EventHub } from './events';
30
+ import { classifyResponse, HttpClient, isRetryableStatus, type HttpResponse } from './http';
31
+ import { Logger } from './logger';
32
+ import { OperationQueue, type ExecutionResult, type QueueItem } from './operationQueue';
33
+ import {
34
+ createsSubscription,
35
+ type CreateSubscriptionOperation,
36
+ type CreateUserOperation,
37
+ type FetchUserOperation,
38
+ type LoginOperation,
39
+ type Operation,
40
+ type RemoveAliasOperation,
41
+ type SetAliasOperation,
42
+ type UpdateUserOperation,
43
+ } from './operations';
44
+ import { parseNotificationPayload } from './payload';
45
+ import type { NativePushRegistration, PlatformAdapter, Subscription } from './platform';
46
+ import { createDefaultState, createUser, sanitizeState, type PersistedState, type PersistedUser } from './state';
47
+ import { readJson, SerialWriter } from './storage';
48
+ import {
49
+ buildSubscriptionFields,
50
+ computeOptedIn,
51
+ diffSubscription,
52
+ isPermissionGranted,
53
+ subscriptionType,
54
+ toSubscriptionPayload,
55
+ type DeviceInfo,
56
+ type SubscriptionFields,
57
+ type SubscriptionInputs,
58
+ type SubscriptionPatch,
59
+ } from './subscription';
60
+ import {
61
+ applyTagChanges,
62
+ changesForAdd,
63
+ changesForRemove,
64
+ isEmpty,
65
+ mergeTagChanges,
66
+ normalizeTags,
67
+ type TagChanges,
68
+ } from './tags';
69
+ import { encodeSegment, normalizeServerUrl } from './url';
70
+ import { generateUuid } from './uuid';
71
+
72
+ export interface CoreEvents {
73
+ click: NotificationClickEvent;
74
+ foregroundWillDisplay: NotificationWillDisplayEvent;
75
+ permissionChange: boolean;
76
+ pushSubscriptionChange: PushSubscriptionChangedState;
77
+ userChange: UserChangedState;
78
+ }
79
+
80
+ /** Aliases that cannot be set / removed through `addAlias` / `removeAlias`. */
81
+ const RESERVED_ALIASES = ['qpush_id', 'onesignal_id', 'external_id'];
82
+
83
+ interface NativeClickPayload {
84
+ rawPayload: unknown;
85
+ actionId?: string | null;
86
+ url?: string | null;
87
+ }
88
+
89
+ interface NativeForegroundPayload {
90
+ displayId: string;
91
+ rawPayload: unknown;
92
+ }
93
+
94
+ function identityQPushId(identity: Record<string, unknown> | undefined): string | undefined {
95
+ if (!identity) return undefined;
96
+ const id = identity.qpush_id ?? identity.onesignal_id;
97
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
98
+ }
99
+
100
+ function aliasesFromIdentity(identity: Record<string, unknown>): Record<string, string> {
101
+ const aliases: Record<string, string> = {};
102
+ for (const key of Object.keys(identity)) {
103
+ const value = identity[key];
104
+ if (key === 'qpush_id' || key === 'onesignal_id') continue;
105
+ if (typeof value === 'string') aliases[key] = value;
106
+ }
107
+ return aliases;
108
+ }
109
+
110
+ function stringTags(tags: unknown): Record<string, string> {
111
+ const result: Record<string, string> = {};
112
+ if (typeof tags !== 'object' || tags === null) return result;
113
+ for (const [key, value] of Object.entries(tags as Record<string, unknown>)) {
114
+ if (value === null || value === undefined || value === '') continue;
115
+ result[key] = typeof value === 'string' ? value : String(value);
116
+ }
117
+ return result;
118
+ }
119
+
120
+ function samePushState(a: PushSubscriptionState, b: PushSubscriptionState): boolean {
121
+ return a.id === b.id && a.token === b.token && a.optedIn === b.optedIn;
122
+ }
123
+
124
+ function permissionToEnum(status: PermissionStatus): NotificationPermission {
125
+ switch (status) {
126
+ case 'granted':
127
+ return NotificationPermission.Authorized;
128
+ case 'denied':
129
+ return NotificationPermission.Denied;
130
+ case 'provisional':
131
+ return NotificationPermission.Provisional;
132
+ case 'ephemeral':
133
+ return NotificationPermission.Ephemeral;
134
+ default:
135
+ return NotificationPermission.NotDetermined;
136
+ }
137
+ }
138
+
139
+ /** Language code from a locale (`pt-BR` -> `pt`, `iw` -> `he`). */
140
+ export function languageFromLocale(locale: string | undefined): string | undefined {
141
+ if (!locale) return undefined;
142
+ const code = locale.split(/[-_]/)[0]?.toLowerCase();
143
+ if (!code) return undefined;
144
+ const legacy: Record<string, string> = { iw: 'he', in: 'id', ji: 'yi' };
145
+ return legacy[code] ?? code;
146
+ }
147
+
148
+ /**
149
+ * The single implementation of the QPush user / subscription / HTTP state
150
+ * machine. Platform specifics are injected through {@link PlatformAdapter}.
151
+ */
152
+ export class QPushCore {
153
+ readonly logger: Logger;
154
+ readonly events = new EventHub<CoreEvents>();
155
+ readonly queue: OperationQueue;
156
+
157
+ private readonly http: HttpClient;
158
+ private readonly stateWriter: SerialWriter;
159
+ private state: PersistedState;
160
+ private loadPromise: Promise<void> | null = null;
161
+ private commandChain: Promise<void> = Promise.resolve();
162
+
163
+ private device: DeviceInfo | null = null;
164
+ private initializeCalled = false;
165
+ private started = false;
166
+ private registrationSettled = false;
167
+ private registering = false;
168
+ private halted = false;
169
+
170
+ private foregroundAt: number | null = null;
171
+ private backgroundAt: number | null = null;
172
+ private pendingColdSession = false;
173
+ private appStateSubscription: Subscription | null = null;
174
+ private nativeSubscriptions: Subscription[] = [];
175
+ private pendingClicks: NotificationClickEvent[] = [];
176
+ private clickFlushScheduled = false;
177
+
178
+ constructor(private readonly platform: PlatformAdapter, logger: Logger = new Logger()) {
179
+ this.logger = logger;
180
+ this.state = createDefaultState(this.uuid());
181
+ this.stateWriter = new SerialWriter(platform.native, StorageKeys.state, (error) =>
182
+ this.logger.error('Failed to persist state', error),
183
+ );
184
+ this.http = new HttpClient({
185
+ fetch: platform.fetch,
186
+ getBaseUrl: () => this.state.config?.serverUrl,
187
+ getSubscriptionId: () => this.state.subscription.id,
188
+ scheduler: platform.scheduler,
189
+ logger: this.logger,
190
+ now: () => platform.now(),
191
+ });
192
+ this.queue = new OperationQueue({
193
+ store: platform.native,
194
+ storageKey: StorageKeys.queue,
195
+ execute: (operation, item) => this.execute(operation, item),
196
+ canProcess: () => this.canProcess(),
197
+ scheduler: platform.scheduler,
198
+ now: () => platform.now(),
199
+ random: () => platform.random(),
200
+ logger: this.logger,
201
+ });
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Plumbing
206
+ // ---------------------------------------------------------------------------
207
+
208
+ private uuid(): string {
209
+ return generateUuid(() => this.platform.random());
210
+ }
211
+
212
+ private ensureLoaded(): Promise<void> {
213
+ if (!this.loadPromise) this.loadPromise = this.load();
214
+ return this.loadPromise;
215
+ }
216
+
217
+ private async load(): Promise<void> {
218
+ const saved = await readJson<unknown>(this.platform.native, StorageKeys.state);
219
+ this.state = sanitizeState(saved, () => this.uuid());
220
+ await this.queue.load();
221
+ // Must be enqueued before any operation of this user (tags set before
222
+ // initialize, consent, ...). Execution waits until the SDK started.
223
+ this.ensureUserCreation();
224
+ }
225
+
226
+ /** Enqueues `createUser` for the current user unless it exists or is being created. */
227
+ private ensureUserCreation(): void {
228
+ const user = this.state.user;
229
+ if (user.qpushId) return;
230
+ const pending = this.queue.has(
231
+ (operation) =>
232
+ (operation.kind === 'createUser' || operation.kind === 'login') && operation.userLocalId === user.localId,
233
+ );
234
+ if (pending) return;
235
+ this.queue.enqueue({
236
+ kind: 'createUser',
237
+ userLocalId: user.localId,
238
+ identity: user.externalId ? { external_id: user.externalId } : {},
239
+ transferSubscription: !!this.state.subscription.id,
240
+ });
241
+ }
242
+
243
+ /** Runs `fn` after state is loaded, serialized with every other command. */
244
+ private command(fn: () => void | Promise<void>): Promise<void> {
245
+ const next = this.commandChain
246
+ .then(() => this.ensureLoaded())
247
+ .then(fn)
248
+ .catch((error) => this.logger.error('Unexpected error', error));
249
+ this.commandChain = next;
250
+ return next;
251
+ }
252
+
253
+ /** Reads state after every previously issued command ran. */
254
+ private async read<T>(fn: () => T): Promise<T> {
255
+ let result!: T;
256
+ await this.command(() => {
257
+ result = fn();
258
+ });
259
+ return result;
260
+ }
261
+
262
+ private save(): void {
263
+ void this.stateWriter.write(JSON.stringify(this.state));
264
+ }
265
+
266
+ private canProcess(): boolean {
267
+ return this.started && this.registrationSettled && !this.halted && this.hasConsent();
268
+ }
269
+
270
+ private hasConsent(): boolean {
271
+ return !this.state.consentRequired || this.state.consentGiven;
272
+ }
273
+
274
+ private sleep(ms: number): Promise<void> {
275
+ return new Promise((resolve) => this.platform.scheduler.setTimeout(resolve, ms));
276
+ }
277
+
278
+ private get appId(): string {
279
+ return encodeSegment(this.state.config?.appId ?? '');
280
+ }
281
+
282
+ /** Waits for pending commands, queue processing and storage writes (tests). */
283
+ async flush(): Promise<void> {
284
+ await this.commandChain;
285
+ await this.queue.process();
286
+ await this.queue.idle();
287
+ await this.commandChain;
288
+ await this.queue.persisted();
289
+ await this.stateWriter.flush();
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Initialization & consent
294
+ // ---------------------------------------------------------------------------
295
+
296
+ setLogLevel(level: LogLevel): void {
297
+ this.logger.setLevel(level);
298
+ try {
299
+ this.platform.native.setLogLevel(level);
300
+ } catch {
301
+ // ignore
302
+ }
303
+ }
304
+
305
+ initialize(appId: string, options?: QPushInitOptions): void {
306
+ if (options && typeof options.logLevel === 'number') this.setLogLevel(options.logLevel);
307
+ if (typeof appId !== 'string' || appId.trim() === '') {
308
+ this.logger.error('initialize: appId is required');
309
+ return;
310
+ }
311
+ const serverUrl = normalizeServerUrl(options?.serverUrl ?? QPUSH_CLOUD_API_URL);
312
+ if (!serverUrl) {
313
+ this.logger.error(`initialize: invalid serverUrl "${String(options?.serverUrl)}"`);
314
+ return;
315
+ }
316
+ const trimmedAppId = appId.trim();
317
+ if (serverUrl.startsWith('http://')) {
318
+ this.logger.warn(
319
+ 'initialize: serverUrl uses plain HTTP. Use HTTPS in production. Release builds on Android block ' +
320
+ 'cleartext traffic (delivery receipts and click reports are sent by native code); on iOS use an IP ' +
321
+ 'address, or NSAllowsLocalNetworking for .local hostnames.',
322
+ );
323
+ }
324
+ this.initializeCalled = true;
325
+ this.attachNativeEvents();
326
+
327
+ void this.command(async () => {
328
+ const previous = this.state.config;
329
+ if (previous && previous.appId === trimmedAppId && previous.serverUrl === serverUrl && this.started) {
330
+ this.logger.debug('initialize: already initialized');
331
+ return;
332
+ }
333
+ if (previous && (previous.appId !== trimmedAppId || previous.serverUrl !== serverUrl)) {
334
+ this.logger.info('initialize: app id or server changed, resetting local user and subscription');
335
+ this.resetForNewApp();
336
+ }
337
+ this.state.config = { appId: trimmedAppId, serverUrl };
338
+ this.save();
339
+ await this.writeNative(NativeKeys.appId, trimmedAppId);
340
+ await this.writeNative(NativeKeys.serverUrl, serverUrl);
341
+ await this.writeNative(NativeKeys.subscriptionId, this.state.subscription.id ?? null);
342
+ this.maybeStart();
343
+ });
344
+ }
345
+
346
+ private resetForNewApp(): void {
347
+ const { consentRequired, consentGiven } = this.state;
348
+ const subscription = this.state.subscription;
349
+ this.queue.clear();
350
+ this.state = createDefaultState(this.uuid());
351
+ this.state.consentRequired = consentRequired;
352
+ this.state.consentGiven = consentGiven;
353
+ // Keep what belongs to the device, drop what belongs to the old app.
354
+ this.state.subscription = {
355
+ optedIn: subscription.optedIn,
356
+ permission: subscription.permission,
357
+ token: subscription.token,
358
+ tokenError: subscription.tokenError,
359
+ };
360
+ this.started = false;
361
+ this.registrationSettled = false;
362
+ this.halted = false;
363
+ this.ensureUserCreation();
364
+ }
365
+
366
+ private async writeNative(key: string, value: string | null): Promise<void> {
367
+ try {
368
+ if (value === null) await this.platform.native.removeItem(key);
369
+ else await this.platform.native.setItem(key, value);
370
+ } catch (error) {
371
+ this.logger.warn(`Could not persist ${key} natively`, error);
372
+ }
373
+ }
374
+
375
+ setConsentRequired(required: boolean): void {
376
+ void this.command(() => {
377
+ this.state.consentRequired = !!required;
378
+ this.save();
379
+ this.maybeStart();
380
+ });
381
+ }
382
+
383
+ setConsentGiven(given: boolean): void {
384
+ void this.command(() => {
385
+ this.state.consentGiven = !!given;
386
+ this.save();
387
+ this.maybeStart();
388
+ if (this.canProcess()) this.queue.kick();
389
+ });
390
+ }
391
+
392
+ private maybeStart(): void {
393
+ if (!this.state.config || this.started) return;
394
+ if (!this.hasConsent()) {
395
+ this.logger.info('Waiting for privacy consent (setConsentGiven(true)) before starting');
396
+ return;
397
+ }
398
+ this.started = true;
399
+ void this.start().catch((error) => this.logger.error('Start failed', error));
400
+ }
401
+
402
+ private async start(): Promise<void> {
403
+ try {
404
+ this.device = await this.platform.native.getDeviceInfo();
405
+ if (this.device && !this.device.language) this.device.language = languageFromLocale(this.device.locale);
406
+ } catch (error) {
407
+ this.logger.warn('Could not read device info', error);
408
+ this.device = { platform: this.platform.os };
409
+ }
410
+
411
+ await this.refreshPermission(true);
412
+
413
+ await this.command(() => {
414
+ this.ensureUserCreation();
415
+ // Pushes changed device fields (app version, OS version...) if any.
416
+ this.mutateSubscription(() => {});
417
+ });
418
+
419
+ this.attachAppState();
420
+ const appState = this.platform.appState.currentState;
421
+ if (appState === 'active' || appState == null || appState === 'unknown') {
422
+ await this.command(() => this.startSession());
423
+ } else {
424
+ this.pendingColdSession = true;
425
+ }
426
+
427
+ await this.command(() => {
428
+ if (this.state.user.qpushId) {
429
+ this.queue.enqueue({ kind: 'fetchUser', userLocalId: this.state.user.localId });
430
+ }
431
+ });
432
+
433
+ await this.registerPush();
434
+ this.registrationSettled = true;
435
+ this.queue.kick();
436
+ }
437
+
438
+ /** Retries push registration when the app comes back without a token (offline start, FCM params added later). */
439
+ private retryRegistrationIfNeeded(): void {
440
+ const sub = this.state.subscription;
441
+ if (!this.registrationSettled || this.registering || this.halted || sub.token) return;
442
+ this.registering = true;
443
+ void this.registerPush()
444
+ .catch((error) => this.logger.warn('Push registration retry failed', error))
445
+ .finally(() => {
446
+ this.registering = false;
447
+ });
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Push registration & permission
452
+ // ---------------------------------------------------------------------------
453
+
454
+ private async fetchParams(): Promise<'ok' | 'not_found' | 'unavailable'> {
455
+ const file = this.platform.os === 'android' ? 'android_params.js' : 'ios_params.js';
456
+ const path = `/apps/${this.appId}/${file}`;
457
+ for (let attempt = 1; attempt <= 3; attempt++) {
458
+ const response = await this.http.request<AndroidParams>('GET', path);
459
+ if (response.ok) {
460
+ if (this.platform.os === 'android') {
461
+ const fcm = response.data && 'fcm' in response.data ? response.data.fcm ?? null : null;
462
+ await this.command(() => {
463
+ this.state.fcmParams = fcm;
464
+ this.save();
465
+ });
466
+ }
467
+ return 'ok';
468
+ }
469
+ if (response.status === 404) return 'not_found';
470
+ if (!isRetryableStatus(response.status)) return 'unavailable';
471
+ if (attempt < 3) await this.sleep(1000 * Math.pow(2, attempt - 1));
472
+ }
473
+ return 'unavailable';
474
+ }
475
+
476
+ private async registerPush(): Promise<void> {
477
+ const params = await this.fetchParams();
478
+ if (params === 'not_found') {
479
+ this.halted = true;
480
+ this.logger.error(
481
+ `App "${this.state.config?.appId}" was not found on ${this.state.config?.serverUrl}. ` +
482
+ 'Check the app id and serverUrl; the SDK will not sync until the app restarts.',
483
+ );
484
+ return;
485
+ }
486
+ if (params === 'unavailable') this.logger.warn('Could not fetch app params, using cached values');
487
+
488
+ const fcm = this.platform.os === 'android' ? this.state.fcmParams ?? null : null;
489
+ let registration: Promise<void>;
490
+ try {
491
+ registration = this.platform.native.registerForPush(fcm).then(
492
+ (result) => this.applyRegistration(result),
493
+ (error) => this.logger.warn('Push registration failed', error),
494
+ );
495
+ } catch (error) {
496
+ this.logger.warn('Push registration failed', error);
497
+ return;
498
+ }
499
+ let timer: unknown;
500
+ const timeout = new Promise<void>((resolve) => {
501
+ timer = this.platform.scheduler.setTimeout(() => {
502
+ this.logger.warn('Push token not received yet, continuing without it');
503
+ resolve();
504
+ }, PUSH_REGISTRATION_TIMEOUT_MS);
505
+ });
506
+ await Promise.race([registration, timeout]);
507
+ this.platform.scheduler.clearTimeout(timer);
508
+ }
509
+
510
+ private applyRegistration(result: NativePushRegistration | null | undefined): Promise<void> {
511
+ return this.command(() => {
512
+ if (!result) return;
513
+ this.mutateSubscription(() => {
514
+ const sub = this.state.subscription;
515
+ if (result.token) {
516
+ sub.token = result.token;
517
+ sub.tokenError = undefined;
518
+ } else if (typeof result.notificationTypes === 'number' && result.notificationTypes < 0) {
519
+ // A transient failure (e.g. offline) must not unsubscribe a device
520
+ // that already has a valid token.
521
+ if (!sub.token) sub.tokenError = result.notificationTypes;
522
+ this.logger.warn(`Push registration error (${result.notificationTypes}): ${result.error ?? 'unknown'}`);
523
+ } else if (result.error) {
524
+ this.logger.warn(`Push registration error: ${result.error}`);
525
+ }
526
+ });
527
+ });
528
+ }
529
+
530
+ private applyToken(token: string): void {
531
+ if (!token) return;
532
+ void this.command(() => {
533
+ this.mutateSubscription(() => {
534
+ this.state.subscription.token = token;
535
+ this.state.subscription.tokenError = undefined;
536
+ });
537
+ });
538
+ }
539
+
540
+ /** Reads the OS permission and updates the subscription when it changed. */
541
+ async refreshPermission(emit: boolean): Promise<PermissionStatus> {
542
+ let status: PermissionStatus;
543
+ try {
544
+ status = await this.platform.native.getPermissionStatus();
545
+ } catch (error) {
546
+ this.logger.warn('Could not read notification permission', error);
547
+ return this.read(() => this.state.subscription.permission);
548
+ }
549
+ await this.command(() => {
550
+ const previous = this.state.subscription.permission;
551
+ if (previous === status) return;
552
+ this.mutateSubscription(() => {
553
+ this.state.subscription.permission = status;
554
+ });
555
+ const wasGranted = isPermissionGranted(previous);
556
+ const isGranted = isPermissionGranted(status);
557
+ if (emit && wasGranted !== isGranted) {
558
+ this.events.emit('permissionChange', isGranted, (error) =>
559
+ this.logger.error('permissionChange listener threw', error),
560
+ );
561
+ }
562
+ });
563
+ return status;
564
+ }
565
+
566
+ async requestPermission(fallbackToSettings: boolean): Promise<boolean> {
567
+ await this.ensureLoaded();
568
+ let granted = false;
569
+ try {
570
+ granted = await this.platform.native.requestPermission(!!fallbackToSettings);
571
+ } catch (error) {
572
+ this.logger.error('requestPermission failed', error);
573
+ }
574
+ const status = await this.refreshPermission(true);
575
+ return granted || isPermissionGranted(status);
576
+ }
577
+
578
+ async registerForProvisionalAuthorization(): Promise<boolean> {
579
+ await this.ensureLoaded();
580
+ let accepted = false;
581
+ try {
582
+ accepted = await this.platform.native.registerForProvisionalAuthorization();
583
+ } catch (error) {
584
+ this.logger.error('registerForProvisionalAuthorization failed', error);
585
+ }
586
+ await this.refreshPermission(true);
587
+ return accepted;
588
+ }
589
+
590
+ async getPermission(): Promise<boolean> {
591
+ await this.ensureLoaded();
592
+ return isPermissionGranted(await this.refreshPermission(true));
593
+ }
594
+
595
+ async permissionNative(): Promise<NotificationPermission> {
596
+ await this.ensureLoaded();
597
+ return permissionToEnum(await this.refreshPermission(true));
598
+ }
599
+
600
+ async canRequestPermission(): Promise<boolean> {
601
+ try {
602
+ return await this.platform.native.canRequestPermission();
603
+ } catch {
604
+ return false;
605
+ }
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Subscription
610
+ // ---------------------------------------------------------------------------
611
+
612
+ private subscriptionInputs(): SubscriptionInputs {
613
+ const sub = this.state.subscription;
614
+ return { token: sub.token, tokenError: sub.tokenError, permission: sub.permission, optedIn: sub.optedIn };
615
+ }
616
+
617
+ private currentFields(): SubscriptionFields {
618
+ return buildSubscriptionFields(this.subscriptionInputs(), this.device);
619
+ }
620
+
621
+ private pushState(): PushSubscriptionState {
622
+ const sub = this.state.subscription;
623
+ return { id: sub.id ?? null, token: sub.token ?? null, optedIn: computeOptedIn(this.subscriptionInputs()) };
624
+ }
625
+
626
+ /** Applies a subscription change, emits `change` and schedules a sync. */
627
+ private mutateSubscription(fn: () => void): void {
628
+ const before = this.pushState();
629
+ fn();
630
+ const after = this.pushState();
631
+ this.save();
632
+ this.scheduleSyncIfDirty();
633
+ if (!samePushState(before, after)) {
634
+ this.events.emit('pushSubscriptionChange', { previous: before, current: after }, (error) =>
635
+ this.logger.error('pushSubscription change listener threw', error),
636
+ );
637
+ }
638
+ }
639
+
640
+ private scheduleSyncIfDirty(): void {
641
+ const sub = this.state.subscription;
642
+ // A pending create sends the live subscription snapshot anyway.
643
+ if (!sub.id && this.queue.has((operation, item) => item.id !== this.queue.inFlight && createsSubscription(operation))) {
644
+ return;
645
+ }
646
+ const dirty = sub.badgeResetPending || !isEmpty(diffSubscription(sub.synced, this.currentFields()));
647
+ if (dirty) this.queue.enqueue({ kind: 'syncSubscription' });
648
+ }
649
+
650
+ private setSubscriptionId(id: string | undefined): void {
651
+ if (this.state.subscription.id === id) return;
652
+ this.mutateSubscription(() => {
653
+ this.state.subscription.id = id;
654
+ });
655
+ void this.writeNative(NativeKeys.subscriptionId, id ?? null);
656
+ }
657
+
658
+ optIn(): void {
659
+ void this.command(async () => {
660
+ this.mutateSubscription(() => {
661
+ this.state.subscription.optedIn = true;
662
+ });
663
+ if (this.state.subscription.permission === 'notDetermined') {
664
+ void this.requestPermission(false);
665
+ }
666
+ });
667
+ }
668
+
669
+ optOut(): void {
670
+ void this.command(() => {
671
+ this.mutateSubscription(() => {
672
+ this.state.subscription.optedIn = false;
673
+ });
674
+ });
675
+ }
676
+
677
+ getSubscriptionId(): Promise<string | null> {
678
+ return this.read(() => this.state.subscription.id ?? null);
679
+ }
680
+
681
+ getSubscriptionToken(): Promise<string | null> {
682
+ return this.read(() => this.state.subscription.token ?? null);
683
+ }
684
+
685
+ getOptedIn(): Promise<boolean> {
686
+ return this.read(() => computeOptedIn(this.subscriptionInputs()));
687
+ }
688
+
689
+ // ---------------------------------------------------------------------------
690
+ // User
691
+ // ---------------------------------------------------------------------------
692
+
693
+ private userState(): UserState {
694
+ return { qpushId: this.state.user.qpushId ?? null, externalId: this.state.user.externalId ?? null };
695
+ }
696
+
697
+ private mutateUser(fn: () => void): void {
698
+ const before = this.userState();
699
+ fn();
700
+ const after = this.userState();
701
+ this.save();
702
+ if (before.qpushId !== after.qpushId || before.externalId !== after.externalId) {
703
+ this.events.emit('userChange', { current: after }, (error) =>
704
+ this.logger.error('User change listener threw', error),
705
+ );
706
+ }
707
+ }
708
+
709
+ login(externalId: string): void {
710
+ void this.command(() => {
711
+ if (typeof externalId !== 'string' || externalId.trim() === '') {
712
+ this.logger.error('login: externalId must be a non-empty string');
713
+ return;
714
+ }
715
+ const previous = this.state.user;
716
+ if (previous.externalId === externalId) {
717
+ this.logger.debug('login: already logged in with this external id');
718
+ return;
719
+ }
720
+ const anonymous = !previous.externalId;
721
+ const next: PersistedUser = {
722
+ ...createUser(this.uuid()),
723
+ externalId,
724
+ // Identifying an anonymous user keeps its data (OneSignal semantics).
725
+ aliases: anonymous ? { ...previous.aliases, external_id: externalId } : { external_id: externalId },
726
+ tags: anonymous ? { ...previous.tags } : {},
727
+ language: previous.language,
728
+ languageExplicit: previous.languageExplicit,
729
+ timezone: previous.timezone,
730
+ };
731
+ this.mutateUser(() => {
732
+ this.state.user = next;
733
+ });
734
+ this.queue.enqueue({
735
+ kind: 'login',
736
+ userLocalId: next.localId,
737
+ externalId,
738
+ previousLocalId: previous.localId,
739
+ previousWasAnonymous: anonymous,
740
+ });
741
+ });
742
+ }
743
+
744
+ logout(): void {
745
+ void this.command(() => {
746
+ const previous = this.state.user;
747
+ if (!previous.externalId) {
748
+ this.logger.debug('logout: no user is logged in');
749
+ return;
750
+ }
751
+ const next: PersistedUser = {
752
+ ...createUser(this.uuid()),
753
+ language: previous.language,
754
+ languageExplicit: previous.languageExplicit,
755
+ timezone: previous.timezone,
756
+ };
757
+ this.mutateUser(() => {
758
+ this.state.user = next;
759
+ });
760
+ this.queue.enqueue({
761
+ kind: 'createUser',
762
+ userLocalId: next.localId,
763
+ identity: {},
764
+ transferSubscription: true,
765
+ });
766
+ });
767
+ }
768
+
769
+ addTags(tags: Record<string, unknown>): void {
770
+ void this.command(() => {
771
+ if (!tags || typeof tags !== 'object') {
772
+ this.logger.error('addTags: expected an object');
773
+ return;
774
+ }
775
+ const [normalized, rejected] = normalizeTags(tags);
776
+ if (rejected.length > 0) {
777
+ this.logger.warn(`addTags: ignored invalid tags: ${rejected.join(', ')} (values must be strings or numbers)`);
778
+ }
779
+ const changes = changesForAdd(this.state.user.tags, normalized);
780
+ this.applyLocalTagChanges(changes);
781
+ });
782
+ }
783
+
784
+ removeTags(keys: string[]): void {
785
+ void this.command(() => {
786
+ if (!Array.isArray(keys)) {
787
+ this.logger.error('removeTags: expected an array of keys');
788
+ return;
789
+ }
790
+ this.applyLocalTagChanges(changesForRemove(keys));
791
+ });
792
+ }
793
+
794
+ private applyLocalTagChanges(changes: TagChanges): void {
795
+ if (isEmpty(changes)) return;
796
+ this.state.user.tags = applyTagChanges(this.state.user.tags, changes);
797
+ this.save();
798
+ this.queue.enqueue({ kind: 'updateUser', userLocalId: this.state.user.localId, tags: changes });
799
+ }
800
+
801
+ getTags(): Promise<Record<string, string>> {
802
+ return this.read(() => ({ ...this.state.user.tags }));
803
+ }
804
+
805
+ addAliases(aliases: Record<string, string>): void {
806
+ void this.command(() => {
807
+ if (!aliases || typeof aliases !== 'object') {
808
+ this.logger.error('addAliases: expected an object');
809
+ return;
810
+ }
811
+ const valid: Record<string, string> = {};
812
+ for (const [label, id] of Object.entries(aliases)) {
813
+ if (typeof label !== 'string' || label.trim() === '' || RESERVED_ALIASES.includes(label)) {
814
+ this.logger.error(`addAlias: "${label}" cannot be used as an alias label (use login() for external_id)`);
815
+ continue;
816
+ }
817
+ if (typeof id !== 'string' || id === '') {
818
+ this.logger.error(`addAlias: the id for "${label}" must be a non-empty string`);
819
+ continue;
820
+ }
821
+ valid[label] = id;
822
+ }
823
+ if (isEmpty(valid)) return;
824
+ this.state.user.aliases = { ...this.state.user.aliases, ...valid };
825
+ this.save();
826
+ this.queue.enqueue({ kind: 'setAlias', userLocalId: this.state.user.localId, aliases: valid });
827
+ });
828
+ }
829
+
830
+ removeAliases(labels: string[]): void {
831
+ void this.command(() => {
832
+ if (!Array.isArray(labels)) return;
833
+ for (const label of labels) {
834
+ if (typeof label !== 'string' || label === '' || RESERVED_ALIASES.includes(label)) {
835
+ this.logger.error(`removeAlias: "${String(label)}" cannot be removed`);
836
+ continue;
837
+ }
838
+ const aliases = { ...this.state.user.aliases };
839
+ delete aliases[label];
840
+ this.state.user.aliases = aliases;
841
+ this.queue.enqueue({ kind: 'removeAlias', userLocalId: this.state.user.localId, label });
842
+ }
843
+ this.save();
844
+ });
845
+ }
846
+
847
+ setLanguage(language: string): void {
848
+ void this.command(() => {
849
+ if (typeof language !== 'string' || language.trim() === '') {
850
+ this.logger.error('setLanguage: language must be a non-empty string');
851
+ return;
852
+ }
853
+ this.state.user.language = language;
854
+ this.state.user.languageExplicit = true;
855
+ this.save();
856
+ this.queue.enqueue({
857
+ kind: 'updateUser',
858
+ userLocalId: this.state.user.localId,
859
+ properties: { language },
860
+ });
861
+ });
862
+ }
863
+
864
+ getQPushId(): Promise<string | null> {
865
+ return this.read(() => this.state.user.qpushId ?? null);
866
+ }
867
+
868
+ getExternalId(): Promise<string | null> {
869
+ return this.read(() => this.state.user.externalId ?? null);
870
+ }
871
+
872
+ // ---------------------------------------------------------------------------
873
+ // Sessions (AppState)
874
+ // ---------------------------------------------------------------------------
875
+
876
+ private attachAppState(): void {
877
+ if (this.appStateSubscription) return;
878
+ try {
879
+ this.appStateSubscription = this.platform.appState.addEventListener('change', (next) =>
880
+ this.onAppStateChange(next),
881
+ );
882
+ } catch (error) {
883
+ this.logger.warn('Could not observe AppState', error);
884
+ }
885
+ }
886
+
887
+ private startSession(): void {
888
+ const now = this.platform.now();
889
+ this.foregroundAt = now;
890
+ this.backgroundAt = null;
891
+ const user = this.state.user;
892
+ const properties: UpdateUserOperation['properties'] = {};
893
+ const timezone = this.device?.timezone;
894
+ if (timezone && timezone !== user.timezone) {
895
+ properties.timezone_id = timezone;
896
+ user.timezone = timezone;
897
+ }
898
+ const language = this.device?.language;
899
+ if (!user.languageExplicit && language && language !== user.language) {
900
+ properties.language = language;
901
+ user.language = language;
902
+ }
903
+ const operation: UpdateUserOperation = {
904
+ kind: 'updateUser',
905
+ userLocalId: user.localId,
906
+ deltas: { session_count: 1 },
907
+ };
908
+ if (!isEmpty(properties)) operation.properties = properties;
909
+ this.save();
910
+ this.queue.enqueue(operation);
911
+ this.logger.debug('New session');
912
+ void this.clearBadge(true);
913
+ }
914
+
915
+ private onAppStateChange(next: string): void {
916
+ if (next === 'active') {
917
+ void this.command(() => {
918
+ const now = this.platform.now();
919
+ if (this.pendingColdSession) {
920
+ this.pendingColdSession = false;
921
+ this.startSession();
922
+ } else if (this.backgroundAt !== null && now - this.backgroundAt >= SESSION_TIMEOUT_MS) {
923
+ this.startSession();
924
+ } else if (this.backgroundAt !== null) {
925
+ this.foregroundAt = now;
926
+ this.backgroundAt = null;
927
+ void this.clearBadge(false);
928
+ }
929
+ });
930
+ void this.refreshPermission(true);
931
+ this.retryRegistrationIfNeeded();
932
+ this.queue.kick();
933
+ } else if (next === 'background') {
934
+ void this.command(() => {
935
+ const now = this.platform.now();
936
+ if (this.foregroundAt !== null) {
937
+ const seconds = Math.round((now - this.foregroundAt) / 1000);
938
+ this.foregroundAt = null;
939
+ if (seconds > 0) {
940
+ this.queue.enqueue({
941
+ kind: 'updateUser',
942
+ userLocalId: this.state.user.localId,
943
+ deltas: { session_time: seconds },
944
+ });
945
+ }
946
+ }
947
+ if (this.backgroundAt === null) this.backgroundAt = now;
948
+ }).then(() => this.flushInBackground());
949
+ }
950
+ }
951
+
952
+ private async flushInBackground(): Promise<void> {
953
+ if (!this.canProcess() || this.queue.size === 0) return;
954
+ let taskId = -1;
955
+ try {
956
+ taskId = await this.platform.native.beginBackgroundTask();
957
+ } catch {
958
+ taskId = -1;
959
+ }
960
+ let timer: unknown;
961
+ try {
962
+ await Promise.race([
963
+ this.queue.process().then(() => this.queue.idle()),
964
+ new Promise<void>((resolve) => {
965
+ timer = this.platform.scheduler.setTimeout(resolve, BACKGROUND_FLUSH_TIMEOUT_MS);
966
+ }),
967
+ ]);
968
+ } finally {
969
+ this.platform.scheduler.clearTimeout(timer);
970
+ if (taskId >= 0) {
971
+ try {
972
+ this.platform.native.endBackgroundTask(taskId);
973
+ } catch {
974
+ // ignore
975
+ }
976
+ }
977
+ }
978
+ }
979
+
980
+ private async clearBadge(newSession: boolean): Promise<void> {
981
+ if (this.platform.os !== 'ios') return;
982
+ let previous = 0;
983
+ try {
984
+ previous = await this.platform.native.clearBadge();
985
+ } catch {
986
+ previous = 0;
987
+ }
988
+ if (previous > 0 || newSession) {
989
+ await this.command(() => {
990
+ this.mutateSubscription(() => {
991
+ this.state.subscription.badgeResetPending = true;
992
+ });
993
+ });
994
+ }
995
+ }
996
+
997
+ // ---------------------------------------------------------------------------
998
+ // Notifications (native events)
999
+ // ---------------------------------------------------------------------------
1000
+
1001
+ /** Subscribes to native events and tells native that JS is ready. Idempotent. */
1002
+ attachNativeEvents(): void {
1003
+ if (this.nativeSubscriptions.length > 0) return;
1004
+ const source = this.platform.nativeEvents;
1005
+ try {
1006
+ this.nativeSubscriptions = [
1007
+ source.addListener(NativeEventNames.click, (payload: NativeClickPayload) => this.handleNativeClick(payload)),
1008
+ source.addListener(NativeEventNames.foregroundWillDisplay, (payload: NativeForegroundPayload) =>
1009
+ this.handleNativeForeground(payload),
1010
+ ),
1011
+ source.addListener(NativeEventNames.pushToken, (payload: { token?: string }) => {
1012
+ if (payload && typeof payload.token === 'string') this.applyToken(payload.token);
1013
+ }),
1014
+ ];
1015
+ this.platform.native.setJsReady();
1016
+ } catch (error) {
1017
+ this.logger.error('Could not subscribe to native events', error);
1018
+ }
1019
+ }
1020
+
1021
+ private handleNativeClick(payload: NativeClickPayload): void {
1022
+ const data = parseNotificationPayload(payload?.rawPayload);
1023
+ if (!data) {
1024
+ this.logger.debug('Ignoring click on a non-QPush notification');
1025
+ return;
1026
+ }
1027
+ const event: NotificationClickEvent = {
1028
+ result: {},
1029
+ notification: new QPushNotification(data),
1030
+ };
1031
+ if (payload.actionId) event.result.actionId = payload.actionId;
1032
+ const url = payload.url ?? undefined;
1033
+ if (url) event.result.url = url;
1034
+ if (this.events.count('click') > 0) {
1035
+ this.emitClick(event);
1036
+ } else {
1037
+ this.pendingClicks.push(event);
1038
+ if (this.pendingClicks.length > MAX_PENDING_CLICKS) this.pendingClicks.shift();
1039
+ }
1040
+ }
1041
+
1042
+ private emitClick(event: NotificationClickEvent): void {
1043
+ this.events.emit('click', event, (error) => this.logger.error('click listener threw', error));
1044
+ }
1045
+
1046
+ private handleNativeForeground(payload: NativeForegroundPayload): void {
1047
+ if (!payload || typeof payload.displayId !== 'string') return;
1048
+ const displayId = payload.displayId;
1049
+ const data = parseNotificationPayload(payload.rawPayload);
1050
+ if (!data) {
1051
+ this.platform.native.displayForegroundNotification(displayId);
1052
+ return;
1053
+ }
1054
+ const event = new NotificationWillDisplayEvent(data, () =>
1055
+ this.platform.native.displayForegroundNotification(displayId),
1056
+ );
1057
+ this.events.emit('foregroundWillDisplay', event, (error) =>
1058
+ this.logger.error('foregroundWillDisplay listener threw', error),
1059
+ );
1060
+ if (event.settle() === 'prevented') {
1061
+ this.platform.native.preventForegroundDisplay(displayId);
1062
+ }
1063
+ }
1064
+
1065
+ addListener<K extends keyof CoreEvents>(event: K, listener: (payload: CoreEvents[K]) => void): void {
1066
+ this.attachNativeEvents();
1067
+ this.events.add(event, listener);
1068
+ if (event === 'click' && this.pendingClicks.length > 0 && !this.clickFlushScheduled) {
1069
+ // Deliver after the caller finished registering (cold start clicks).
1070
+ this.clickFlushScheduled = true;
1071
+ this.platform.scheduler.setTimeout(() => {
1072
+ this.clickFlushScheduled = false;
1073
+ const pending = this.pendingClicks;
1074
+ this.pendingClicks = [];
1075
+ for (const click of pending) this.emitClick(click);
1076
+ }, 0);
1077
+ }
1078
+ }
1079
+
1080
+ removeListener<K extends keyof CoreEvents>(event: K, listener: (payload: CoreEvents[K]) => void): void {
1081
+ this.events.remove(event, listener);
1082
+ }
1083
+
1084
+ clearAll(): void {
1085
+ try {
1086
+ this.platform.native.clearAll();
1087
+ } catch (error) {
1088
+ this.logger.error('clearAll failed', error);
1089
+ }
1090
+ void this.clearBadge(false);
1091
+ }
1092
+
1093
+ removeNotification(notificationId: string): void {
1094
+ try {
1095
+ this.platform.native.removeNotification(notificationId);
1096
+ } catch (error) {
1097
+ this.logger.error('removeNotification failed', error);
1098
+ }
1099
+ }
1100
+
1101
+ removeGroupedNotifications(groupKey: string): void {
1102
+ try {
1103
+ this.platform.native.removeGroupedNotifications(groupKey);
1104
+ } catch (error) {
1105
+ this.logger.error('removeGroupedNotifications failed', error);
1106
+ }
1107
+ }
1108
+
1109
+ setAutoOpenUrls(enabled: boolean): void {
1110
+ try {
1111
+ this.platform.native.setAutoOpenUrls(!!enabled);
1112
+ } catch (error) {
1113
+ this.logger.error('setAutoOpenUrls failed', error);
1114
+ }
1115
+ }
1116
+
1117
+ // ---------------------------------------------------------------------------
1118
+ // Operation execution
1119
+ // ---------------------------------------------------------------------------
1120
+
1121
+ private async execute(operation: Operation, item: QueueItem): Promise<ExecutionResult> {
1122
+ if (!this.state.config) return { type: 'retry', countAttempt: false };
1123
+ switch (operation.kind) {
1124
+ case 'createUser':
1125
+ return this.execCreateUser(operation);
1126
+ case 'login':
1127
+ return this.execLogin(operation);
1128
+ case 'updateUser':
1129
+ return this.execUpdateUser(operation);
1130
+ case 'setAlias':
1131
+ return this.execSetAlias(operation);
1132
+ case 'removeAlias':
1133
+ return this.execRemoveAlias(operation);
1134
+ case 'fetchUser':
1135
+ return this.execFetchUser(operation);
1136
+ case 'syncSubscription':
1137
+ return this.execSyncSubscription(item);
1138
+ case 'createSubscription':
1139
+ return this.execCreateSubscription(operation);
1140
+ default:
1141
+ return { type: 'drop', reason: 'unknown operation' };
1142
+ }
1143
+ }
1144
+
1145
+ private userPath(qpushId: string): string {
1146
+ return `/apps/${this.appId}/users/by/qpush_id/${encodeSegment(qpushId)}`;
1147
+ }
1148
+
1149
+ private userProperties(localId: string): NonNullable<UserPayload['properties']> {
1150
+ const user = this.state.user.localId === localId ? this.state.user : undefined;
1151
+ const properties: NonNullable<UserPayload['properties']> = {};
1152
+ const language = user?.language ?? this.device?.language;
1153
+ if (language) properties.language = language;
1154
+ if (this.device?.timezone) properties.timezone_id = this.device.timezone;
1155
+ if (this.device?.country) properties.country = this.device.country;
1156
+ return properties;
1157
+ }
1158
+
1159
+ /** Tag changes still waiting in the queue for a user (to re-apply over server data). */
1160
+ private pendingTagChanges(localId: string): TagChanges {
1161
+ let changes: TagChanges = {};
1162
+ for (const item of this.queue.pending) {
1163
+ if (item.id === this.queue.inFlight) continue;
1164
+ const operation = item.operation;
1165
+ if (operation.kind === 'updateUser' && operation.userLocalId === localId && operation.tags) {
1166
+ changes = mergeTagChanges(changes, operation.tags);
1167
+ }
1168
+ }
1169
+ return changes;
1170
+ }
1171
+
1172
+ private pendingAliasChanges(localId: string, base: Record<string, string>): Record<string, string> {
1173
+ const aliases = { ...base };
1174
+ for (const item of this.queue.pending) {
1175
+ if (item.id === this.queue.inFlight) continue;
1176
+ const operation = item.operation;
1177
+ if (operation.kind === 'setAlias' && operation.userLocalId === localId) Object.assign(aliases, operation.aliases);
1178
+ if (operation.kind === 'removeAlias' && operation.userLocalId === localId) delete aliases[operation.label];
1179
+ }
1180
+ return aliases;
1181
+ }
1182
+
1183
+ private mapUser(localId: string, qpushId: string, identity?: Record<string, unknown>): void {
1184
+ this.state.idMap[localId] = qpushId;
1185
+ if (this.state.user.localId === localId) {
1186
+ this.mutateUser(() => {
1187
+ const user = this.state.user;
1188
+ user.qpushId = qpushId;
1189
+ if (identity) {
1190
+ user.aliases = this.pendingAliasChanges(localId, aliasesFromIdentity(identity));
1191
+ const externalId = identity.external_id;
1192
+ if (typeof externalId === 'string' && externalId) user.externalId = externalId;
1193
+ }
1194
+ });
1195
+ }
1196
+ this.pruneIdMap();
1197
+ this.save();
1198
+ }
1199
+
1200
+ /** Keeps only ids still referenced by the current user or pending operations. */
1201
+ private pruneIdMap(): void {
1202
+ const referenced = new Set<string>([this.state.user.localId]);
1203
+ for (const item of this.queue.pending) {
1204
+ const operation = item.operation;
1205
+ if ('userLocalId' in operation) referenced.add(operation.userLocalId);
1206
+ if (operation.kind === 'login') referenced.add(operation.previousLocalId);
1207
+ }
1208
+ for (const key of Object.keys(this.state.idMap)) {
1209
+ if (!referenced.has(key)) delete this.state.idMap[key];
1210
+ }
1211
+ }
1212
+
1213
+ /** Picks this device's subscription in a user response. */
1214
+ private findOwnSubscription(
1215
+ subscriptions: SubscriptionPayload[] | undefined,
1216
+ sent: { id?: string; token?: string },
1217
+ ): SubscriptionPayload | undefined {
1218
+ if (!Array.isArray(subscriptions) || subscriptions.length === 0) return undefined;
1219
+ const type = subscriptionType(this.platform.os);
1220
+ if (sent.id) {
1221
+ const byId = subscriptions.find((sub) => sub.id === sent.id);
1222
+ if (byId) return byId;
1223
+ }
1224
+ if (sent.token) {
1225
+ const byToken = subscriptions.find((sub) => sub.token === sent.token);
1226
+ if (byToken) return byToken;
1227
+ } else {
1228
+ const tokenless = subscriptions.filter((sub) => sub.type === type && !sub.token);
1229
+ if (tokenless.length === 1) return tokenless[0];
1230
+ }
1231
+ return subscriptions.length === 1 ? subscriptions[0] : undefined;
1232
+ }
1233
+
1234
+ /** Applies a `POST /users` response (createUser, login fallback). */
1235
+ private applyCreatedUser(
1236
+ localId: string,
1237
+ data: UserPayload | undefined,
1238
+ sentFields: SubscriptionFields,
1239
+ sentId: string | undefined,
1240
+ repushLocalTags: boolean,
1241
+ ): ExecutionResult {
1242
+ const identity = (data?.identity ?? {}) as Record<string, unknown>;
1243
+ const qpushId = identityQPushId(identity);
1244
+ if (!qpushId) return { type: 'drop', reason: 'response without qpush_id' };
1245
+ this.mapUser(localId, qpushId, identity);
1246
+
1247
+ if (this.state.user.localId === localId) {
1248
+ const serverTags = stringTags(data?.properties?.tags);
1249
+ const pending = this.pendingTagChanges(localId);
1250
+ const local = this.state.user.tags;
1251
+ // Re-push local tags the server does not have (never removals: a fresh
1252
+ // install must not wipe the tags of a user it was re-attached to).
1253
+ const missing: TagChanges = {};
1254
+ if (repushLocalTags) {
1255
+ for (const [key, value] of Object.entries(local)) {
1256
+ if (serverTags[key] !== value && !(key in pending)) missing[key] = value;
1257
+ }
1258
+ }
1259
+ this.state.user.tags = applyTagChanges(applyTagChanges(serverTags, missing), pending);
1260
+ if (this.device?.timezone) this.state.user.timezone = this.device.timezone;
1261
+ if (!isEmpty(missing)) {
1262
+ this.queue.enqueue({ kind: 'updateUser', userLocalId: localId, tags: missing });
1263
+ }
1264
+ }
1265
+
1266
+ const own = this.findOwnSubscription(data?.subscriptions, { id: sentId, token: sentFields.token });
1267
+ if (own?.id) {
1268
+ this.state.subscription.synced = { ...sentFields };
1269
+ this.setSubscriptionId(own.id);
1270
+ } else {
1271
+ this.logger.warn('Could not identify this device subscription in the server response');
1272
+ }
1273
+ this.save();
1274
+ this.scheduleSyncIfDirty();
1275
+ return { type: 'success' };
1276
+ }
1277
+
1278
+ private async postUser(
1279
+ localId: string,
1280
+ identity: Record<string, string>,
1281
+ transferSubscription: boolean,
1282
+ repushLocalTags: boolean,
1283
+ ): Promise<ExecutionResult> {
1284
+ const fields = this.currentFields();
1285
+ const sentId = transferSubscription ? this.state.subscription.id : undefined;
1286
+ const body: UserPayload = {
1287
+ identity,
1288
+ properties: this.userProperties(localId),
1289
+ subscriptions: [toSubscriptionPayload(this.platform.os, fields, sentId)],
1290
+ };
1291
+ const response = await this.http.request<UserPayload>('POST', `/apps/${this.appId}/users`, body);
1292
+ if (!response.ok) return classifyResponse(response);
1293
+ return this.applyCreatedUser(localId, response.data, fields, sentId, repushLocalTags);
1294
+ }
1295
+
1296
+ private execCreateUser(operation: CreateUserOperation): Promise<ExecutionResult> {
1297
+ return this.postUser(operation.userLocalId, operation.identity, operation.transferSubscription, true);
1298
+ }
1299
+
1300
+ private async execLogin(operation: LoginOperation): Promise<ExecutionResult> {
1301
+ const identity = { external_id: operation.externalId };
1302
+
1303
+ // 1. Identify the anonymous user (keeps its tags), like OneSignal.
1304
+ const previousQPushId = this.state.idMap[operation.previousLocalId];
1305
+ // Anonymous data carries over only when the anonymous user is identified
1306
+ // (or never reached the server); it is discarded when switching users.
1307
+ let carryAnonymousData = operation.previousWasAnonymous;
1308
+ if (operation.previousWasAnonymous && previousQPushId) {
1309
+ const response = await this.http.request<{ identity?: Record<string, string> }>(
1310
+ 'PATCH',
1311
+ `${this.userPath(previousQPushId)}/identity`,
1312
+ { identity },
1313
+ );
1314
+ if (response.ok) {
1315
+ this.mapUser(operation.userLocalId, previousQPushId, response.data?.identity ?? identity);
1316
+ return { type: 'success' };
1317
+ }
1318
+ if (response.status !== 409 && response.status !== 404) return classifyResponse(response);
1319
+ carryAnonymousData = false;
1320
+ this.logger.debug(`login: external id belongs to another user (${response.status}), switching user`);
1321
+ }
1322
+
1323
+ // 2. Move this device's subscription to the user owning the external id.
1324
+ const subscriptionId = this.state.subscription.id;
1325
+ if (subscriptionId) {
1326
+ const response = await this.http.request<{ identity?: Record<string, string> }>(
1327
+ 'PATCH',
1328
+ `/apps/${this.appId}/subscriptions/${encodeSegment(subscriptionId)}/owner`,
1329
+ { identity },
1330
+ );
1331
+ if (response.ok) {
1332
+ const qpushId = identityQPushId(response.data?.identity);
1333
+ if (!qpushId) return { type: 'drop', reason: 'owner response without qpush_id' };
1334
+ this.mapUser(operation.userLocalId, qpushId, response.data?.identity ?? identity);
1335
+ // The device switched to an existing user: refresh tags & aliases.
1336
+ this.queue.enqueue({ kind: 'fetchUser', userLocalId: operation.userLocalId });
1337
+ return { type: 'success' };
1338
+ }
1339
+ if (response.status !== 404) return classifyResponse(response);
1340
+ this.setSubscriptionId(undefined);
1341
+ this.state.subscription.synced = undefined;
1342
+ }
1343
+
1344
+ // 3. No subscription on the server yet: create the user with it.
1345
+ return this.postUser(operation.userLocalId, identity, false, carryAnonymousData);
1346
+ }
1347
+
1348
+ private handleMissingUser(localId: string): void {
1349
+ delete this.state.idMap[localId];
1350
+ if (this.state.user.localId !== localId) return;
1351
+ this.logger.warn('The current user no longer exists on the server, creating it again');
1352
+ const previous = this.state.user;
1353
+ const next: PersistedUser = { ...previous, localId: this.uuid(), qpushId: undefined };
1354
+ this.mutateUser(() => {
1355
+ this.state.user = next;
1356
+ });
1357
+ this.queue.enqueue({
1358
+ kind: 'createUser',
1359
+ userLocalId: next.localId,
1360
+ identity: next.externalId ? { external_id: next.externalId } : {},
1361
+ transferSubscription: true,
1362
+ });
1363
+ }
1364
+
1365
+ private resolveQPushId(localId: string): string | undefined {
1366
+ return this.state.idMap[localId] ?? (this.state.user.localId === localId ? this.state.user.qpushId : undefined);
1367
+ }
1368
+
1369
+ private async execUpdateUser(operation: UpdateUserOperation): Promise<ExecutionResult> {
1370
+ const qpushId = this.resolveQPushId(operation.userLocalId);
1371
+ if (!qpushId) return { type: 'drop', reason: 'user was never created on the server' };
1372
+ const properties: Record<string, unknown> = { ...(operation.properties ?? {}) };
1373
+ if (!isEmpty(operation.tags)) properties.tags = operation.tags;
1374
+ const body: Record<string, unknown> = {};
1375
+ if (!isEmpty(properties)) body.properties = properties;
1376
+ if (!isEmpty(operation.deltas)) body.deltas = operation.deltas;
1377
+ if (isEmpty(body)) return { type: 'success' };
1378
+ const response = await this.http.request('PATCH', this.userPath(qpushId), body);
1379
+ if (response.status === 404) {
1380
+ this.handleMissingUser(operation.userLocalId);
1381
+ return { type: 'drop', reason: 'user not found' };
1382
+ }
1383
+ return classifyResponse(response);
1384
+ }
1385
+
1386
+ private async execSetAlias(operation: SetAliasOperation): Promise<ExecutionResult> {
1387
+ const qpushId = this.resolveQPushId(operation.userLocalId);
1388
+ if (!qpushId) return { type: 'drop', reason: 'user was never created on the server' };
1389
+ const response = await this.http.request<{ identity?: Record<string, string> }>(
1390
+ 'PATCH',
1391
+ `${this.userPath(qpushId)}/identity`,
1392
+ { identity: operation.aliases },
1393
+ );
1394
+ if (response.ok) {
1395
+ if (response.data?.identity && this.state.user.localId === operation.userLocalId) {
1396
+ this.mapUser(operation.userLocalId, qpushId, response.data.identity);
1397
+ }
1398
+ return { type: 'success' };
1399
+ }
1400
+ if (response.status === 409) {
1401
+ this.logger.error(`addAlias: alias already claimed by another user (${Object.keys(operation.aliases).join(', ')})`);
1402
+ if (this.state.user.localId === operation.userLocalId) {
1403
+ const aliases = { ...this.state.user.aliases };
1404
+ for (const [label, id] of Object.entries(operation.aliases)) if (aliases[label] === id) delete aliases[label];
1405
+ this.state.user.aliases = aliases;
1406
+ this.save();
1407
+ }
1408
+ return { type: 'drop', reason: 'alias_claimed' };
1409
+ }
1410
+ if (response.status === 404) {
1411
+ this.handleMissingUser(operation.userLocalId);
1412
+ return { type: 'drop', reason: 'user not found' };
1413
+ }
1414
+ return classifyResponse(response);
1415
+ }
1416
+
1417
+ private async execRemoveAlias(operation: RemoveAliasOperation): Promise<ExecutionResult> {
1418
+ const qpushId = this.resolveQPushId(operation.userLocalId);
1419
+ if (!qpushId) return { type: 'drop', reason: 'user was never created on the server' };
1420
+ const response = await this.http.request(
1421
+ 'DELETE',
1422
+ `${this.userPath(qpushId)}/identity/${encodeSegment(operation.label)}`,
1423
+ );
1424
+ return classifyResponse(response);
1425
+ }
1426
+
1427
+ private async execFetchUser(operation: FetchUserOperation): Promise<ExecutionResult> {
1428
+ const qpushId = this.resolveQPushId(operation.userLocalId);
1429
+ if (!qpushId) return { type: 'drop', reason: 'user was never created on the server' };
1430
+ const response = await this.http.request<UserPayload>('GET', this.userPath(qpushId));
1431
+ if (response.status === 404) {
1432
+ this.handleMissingUser(operation.userLocalId);
1433
+ return { type: 'drop', reason: 'user not found' };
1434
+ }
1435
+ if (!response.ok) return classifyResponse(response);
1436
+ if (this.state.user.localId === operation.userLocalId && response.data) {
1437
+ const identity = (response.data.identity ?? {}) as Record<string, unknown>;
1438
+ this.mapUser(operation.userLocalId, identityQPushId(identity) ?? qpushId, identity);
1439
+ const serverTags = stringTags(response.data.properties?.tags);
1440
+ this.state.user.tags = applyTagChanges(serverTags, this.pendingTagChanges(operation.userLocalId));
1441
+ this.save();
1442
+ }
1443
+ return { type: 'success' };
1444
+ }
1445
+
1446
+ private async execSyncSubscription(item: QueueItem): Promise<ExecutionResult> {
1447
+ const sub = this.state.subscription;
1448
+ if (!sub.id) {
1449
+ // A pending create carries the live snapshot; nothing to do now.
1450
+ if (this.queue.has((operation, other) => other.id !== item.id && createsSubscription(operation))) {
1451
+ return { type: 'success' };
1452
+ }
1453
+ if (!this.state.user.qpushId) return { type: 'success' };
1454
+ return this.execCreateSubscription({ kind: 'createSubscription', userLocalId: this.state.user.localId });
1455
+ }
1456
+ const fields = this.currentFields();
1457
+ const patch: SubscriptionPatch = diffSubscription(sub.synced, fields);
1458
+ const resetBadge = !!sub.badgeResetPending;
1459
+ if (resetBadge) patch.badge_count = 0;
1460
+ if (isEmpty(patch)) return { type: 'success' };
1461
+
1462
+ const response: HttpResponse = await this.http.request(
1463
+ 'PATCH',
1464
+ `/apps/${this.appId}/subscriptions/${encodeSegment(sub.id)}`,
1465
+ { subscription: patch },
1466
+ );
1467
+ if (response.ok) {
1468
+ const { badge_count: _ignored, ...sent } = patch;
1469
+ void _ignored;
1470
+ sub.synced = { ...(sub.synced ?? {}), ...sent };
1471
+ if (resetBadge) sub.badgeResetPending = false;
1472
+ this.save();
1473
+ this.scheduleSyncIfDirty();
1474
+ return { type: 'success' };
1475
+ }
1476
+ if (response.status === 404) {
1477
+ this.logger.warn('Subscription not found on the server, creating it again');
1478
+ sub.synced = undefined;
1479
+ this.setSubscriptionId(undefined);
1480
+ this.queue.enqueue({ kind: 'createSubscription', userLocalId: this.state.user.localId });
1481
+ return { type: 'drop', reason: 'subscription not found' };
1482
+ }
1483
+ return classifyResponse(response);
1484
+ }
1485
+
1486
+ private async execCreateSubscription(operation: CreateSubscriptionOperation): Promise<ExecutionResult> {
1487
+ if (this.state.subscription.id) return { type: 'success' };
1488
+ const qpushId = this.resolveQPushId(operation.userLocalId);
1489
+ if (!qpushId) return { type: 'drop', reason: 'user was never created on the server' };
1490
+ const fields = this.currentFields();
1491
+ const response = await this.http.request<{ subscription?: SubscriptionPayload }>(
1492
+ 'POST',
1493
+ `${this.userPath(qpushId)}/subscriptions`,
1494
+ { subscription: toSubscriptionPayload(this.platform.os, fields) },
1495
+ );
1496
+ if (response.ok) {
1497
+ const id = response.data?.subscription?.id;
1498
+ if (!id) return { type: 'drop', reason: 'response without subscription id' };
1499
+ this.state.subscription.synced = { ...fields };
1500
+ this.setSubscriptionId(id);
1501
+ this.scheduleSyncIfDirty();
1502
+ return { type: 'success' };
1503
+ }
1504
+ if (response.status === 404) {
1505
+ this.handleMissingUser(operation.userLocalId);
1506
+ return { type: 'drop', reason: 'user not found' };
1507
+ }
1508
+ return classifyResponse(response);
1509
+ }
1510
+
1511
+ /** @internal Used by the facade to know whether `initialize` was called. */
1512
+ get isInitialized(): boolean {
1513
+ return this.initializeCalled;
1514
+ }
1515
+ }