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.
- package/README.md +439 -2
- package/android/build.gradle +77 -0
- package/android/gradle.properties +5 -0
- package/android/proguard-rules.pro +5 -0
- package/android/src/main/AndroidManifest.xml +29 -0
- package/android/src/main/java/com/qpush/reactnative/QPushDeviceInfo.kt +96 -0
- package/android/src/main/java/com/qpush/reactnative/QPushEventBus.kt +153 -0
- package/android/src/main/java/com/qpush/reactnative/QPushFcm.kt +108 -0
- package/android/src/main/java/com/qpush/reactnative/QPushHttp.kt +92 -0
- package/android/src/main/java/com/qpush/reactnative/QPushImages.kt +103 -0
- package/android/src/main/java/com/qpush/reactnative/QPushLog.kt +39 -0
- package/android/src/main/java/com/qpush/reactnative/QPushMessagingService.kt +77 -0
- package/android/src/main/java/com/qpush/reactnative/QPushNotificationOpenActivity.kt +121 -0
- package/android/src/main/java/com/qpush/reactnative/QPushNotificationRenderer.kt +256 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPayload.kt +114 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPushModule.kt +368 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPushPackage.kt +15 -0
- package/android/src/main/java/com/qpush/reactnative/QPushStore.kt +108 -0
- package/android/src/main/res/values/qpush_strings.xml +6 -0
- package/app.plugin.js +1 -0
- package/ios/Extension/QPushNotificationServiceExtension.swift +261 -0
- package/ios/Extension/QPushPushExtension.podspec +34 -0
- package/ios/QPushPush.m +62 -0
- package/ios/QPushPush.swift +186 -0
- package/ios/QPushPushBridge.h +5 -0
- package/ios/QPushPushCore.swift +457 -0
- package/ios/QPushPushHooks.m +237 -0
- package/ios/QPushPushSupport.swift +249 -0
- package/ios_nse_template/Info.plist +38 -0
- package/ios_nse_template/NotificationService.entitlements +10 -0
- package/ios_nse_template/NotificationService.swift +44 -0
- package/lib/NativeQPushPush.d.ts +44 -0
- package/lib/NativeQPushPush.js +4 -0
- package/lib/QPush.d.ts +117 -0
- package/lib/QPush.js +206 -0
- package/lib/core/QPushCore.d.ts +148 -0
- package/lib/core/QPushCore.js +1387 -0
- package/lib/core/constants.d.ts +37 -0
- package/lib/core/constants.js +40 -0
- package/lib/core/events.d.ts +11 -0
- package/lib/core/events.js +40 -0
- package/lib/core/http.d.ts +41 -0
- package/lib/core/http.js +123 -0
- package/lib/core/logger.d.ts +18 -0
- package/lib/core/logger.js +43 -0
- package/lib/core/operationQueue.d.ts +117 -0
- package/lib/core/operationQueue.js +264 -0
- package/lib/core/operations.d.ts +80 -0
- package/lib/core/operations.js +88 -0
- package/lib/core/payload.d.ts +20 -0
- package/lib/core/payload.js +187 -0
- package/lib/core/platform.d.ts +72 -0
- package/lib/core/platform.js +7 -0
- package/lib/core/state.d.ts +51 -0
- package/lib/core/state.js +74 -0
- package/lib/core/storage.d.ts +34 -0
- package/lib/core/storage.js +68 -0
- package/lib/core/subscription.d.ts +67 -0
- package/lib/core/subscription.js +98 -0
- package/lib/core/tags.d.ts +39 -0
- package/lib/core/tags.js +123 -0
- package/lib/core/url.d.ts +17 -0
- package/lib/core/url.js +64 -0
- package/lib/core/uuid.d.ts +2 -0
- package/lib/core/uuid.js +18 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +15 -0
- package/lib/notification.d.ts +67 -0
- package/lib/notification.js +73 -0
- package/lib/reactNativePlatform.d.ts +3 -0
- package/lib/reactNativePlatform.js +164 -0
- package/lib/types.d.ts +157 -0
- package/lib/types.js +31 -0
- package/package.json +70 -4
- package/plugin/withQPush.js +330 -0
- package/react-native-qpush.podspec +30 -0
- package/react-native.config.js +18 -0
- package/src/NativeQPushPush.ts +57 -0
- package/src/QPush.ts +292 -0
- package/src/core/QPushCore.ts +1515 -0
- package/src/core/constants.ts +42 -0
- package/src/core/events.ts +34 -0
- package/src/core/http.ts +146 -0
- package/src/core/logger.ts +42 -0
- package/src/core/operationQueue.ts +324 -0
- package/src/core/operations.ts +166 -0
- package/src/core/payload.ts +175 -0
- package/src/core/platform.ts +84 -0
- package/src/core/state.ts +127 -0
- package/src/core/storage.ts +76 -0
- package/src/core/subscription.ts +133 -0
- package/src/core/tags.ts +116 -0
- package/src/core/url.ts +58 -0
- package/src/core/uuid.ts +13 -0
- package/src/index.ts +23 -0
- package/src/notification.ts +113 -0
- package/src/reactNativePlatform.ts +172 -0
- package/src/types.ts +174 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mergeTagChanges, type TagChanges } from './tags';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Operations are persisted in the queue as plain JSON. Every user-scoped
|
|
5
|
+
* operation references the user by a *local id* generated on the device; the
|
|
6
|
+
* real `qpush_id` is resolved when the operation executes (the user may not
|
|
7
|
+
* exist on the server yet when the operation is enqueued). This keeps changes
|
|
8
|
+
* made before `login()`/`logout()` attached to the right user.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** `POST /apps/{app_id}/users` (cold start for a new device, and `logout()`). */
|
|
12
|
+
export interface CreateUserOperation {
|
|
13
|
+
kind: 'createUser';
|
|
14
|
+
userLocalId: string;
|
|
15
|
+
identity: Record<string, string>;
|
|
16
|
+
/** Include the current subscription id so the server moves it (logout). */
|
|
17
|
+
transferSubscription: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `login(externalId)`: identify the anonymous user or switch users. */
|
|
21
|
+
export interface LoginOperation {
|
|
22
|
+
kind: 'login';
|
|
23
|
+
userLocalId: string;
|
|
24
|
+
externalId: string;
|
|
25
|
+
previousLocalId: string;
|
|
26
|
+
previousWasAnonymous: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `PATCH /apps/{app_id}/users/by/qpush_id/{id}` */
|
|
30
|
+
export interface UpdateUserOperation {
|
|
31
|
+
kind: 'updateUser';
|
|
32
|
+
userLocalId: string;
|
|
33
|
+
tags?: TagChanges;
|
|
34
|
+
properties?: {
|
|
35
|
+
language?: string;
|
|
36
|
+
timezone_id?: string;
|
|
37
|
+
country?: string;
|
|
38
|
+
};
|
|
39
|
+
deltas?: {
|
|
40
|
+
session_count?: number;
|
|
41
|
+
session_time?: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `PATCH .../identity` */
|
|
46
|
+
export interface SetAliasOperation {
|
|
47
|
+
kind: 'setAlias';
|
|
48
|
+
userLocalId: string;
|
|
49
|
+
aliases: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `DELETE .../identity/{label}` */
|
|
53
|
+
export interface RemoveAliasOperation {
|
|
54
|
+
kind: 'removeAlias';
|
|
55
|
+
userLocalId: string;
|
|
56
|
+
label: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `GET /apps/{app_id}/users/by/qpush_id/{id}` to refresh the local cache. */
|
|
60
|
+
export interface FetchUserOperation {
|
|
61
|
+
kind: 'fetchUser';
|
|
62
|
+
userLocalId: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* State based: computes the diff between the local subscription and what the
|
|
67
|
+
* server acknowledged at execution time, then `PATCH /subscriptions/{id}`.
|
|
68
|
+
*/
|
|
69
|
+
export interface SyncSubscriptionOperation {
|
|
70
|
+
kind: 'syncSubscription';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `POST .../users/by/qpush_id/{id}/subscriptions` (subscription lost server side). */
|
|
74
|
+
export interface CreateSubscriptionOperation {
|
|
75
|
+
kind: 'createSubscription';
|
|
76
|
+
userLocalId: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Operation =
|
|
80
|
+
| CreateUserOperation
|
|
81
|
+
| LoginOperation
|
|
82
|
+
| UpdateUserOperation
|
|
83
|
+
| SetAliasOperation
|
|
84
|
+
| RemoveAliasOperation
|
|
85
|
+
| FetchUserOperation
|
|
86
|
+
| SyncSubscriptionOperation
|
|
87
|
+
| CreateSubscriptionOperation;
|
|
88
|
+
|
|
89
|
+
export type OperationKind = Operation['kind'];
|
|
90
|
+
|
|
91
|
+
function sumDelta(a: number | undefined, b: number | undefined): number | undefined {
|
|
92
|
+
if (a === undefined) return b;
|
|
93
|
+
if (b === undefined) return a;
|
|
94
|
+
return a + b;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function stripEmpty<T extends Record<string, unknown>>(value: T | undefined): T | undefined {
|
|
98
|
+
if (!value) return undefined;
|
|
99
|
+
const result: Record<string, unknown> = {};
|
|
100
|
+
for (const key of Object.keys(value)) {
|
|
101
|
+
if (value[key] !== undefined) result[key] = value[key];
|
|
102
|
+
}
|
|
103
|
+
return Object.keys(result).length > 0 ? (result as T) : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Tries to merge `next` into `previous` (the tail of the queue). Returns the
|
|
108
|
+
* merged operation, or `null` when they cannot be merged.
|
|
109
|
+
*/
|
|
110
|
+
export function mergeOperations(previous: Operation, next: Operation): Operation | null {
|
|
111
|
+
if (previous.kind === 'updateUser' && next.kind === 'updateUser') {
|
|
112
|
+
if (previous.userLocalId !== next.userLocalId) return null;
|
|
113
|
+
const tags =
|
|
114
|
+
previous.tags || next.tags ? mergeTagChanges(previous.tags ?? {}, next.tags ?? {}) : undefined;
|
|
115
|
+
const properties = stripEmpty({ ...(previous.properties ?? {}), ...(next.properties ?? {}) });
|
|
116
|
+
const deltas = stripEmpty({
|
|
117
|
+
session_count: sumDelta(previous.deltas?.session_count, next.deltas?.session_count),
|
|
118
|
+
session_time: sumDelta(previous.deltas?.session_time, next.deltas?.session_time),
|
|
119
|
+
});
|
|
120
|
+
const merged: UpdateUserOperation = { kind: 'updateUser', userLocalId: next.userLocalId };
|
|
121
|
+
if (tags && Object.keys(tags).length > 0) merged.tags = tags;
|
|
122
|
+
if (properties) merged.properties = properties;
|
|
123
|
+
if (deltas) merged.deltas = deltas;
|
|
124
|
+
return merged;
|
|
125
|
+
}
|
|
126
|
+
if (previous.kind === 'setAlias' && next.kind === 'setAlias') {
|
|
127
|
+
if (previous.userLocalId !== next.userLocalId) return null;
|
|
128
|
+
return { kind: 'setAlias', userLocalId: next.userLocalId, aliases: { ...previous.aliases, ...next.aliases } };
|
|
129
|
+
}
|
|
130
|
+
if (previous.kind === 'syncSubscription' && next.kind === 'syncSubscription') {
|
|
131
|
+
return previous;
|
|
132
|
+
}
|
|
133
|
+
if (previous.kind === 'fetchUser' && next.kind === 'fetchUser' && previous.userLocalId === next.userLocalId) {
|
|
134
|
+
return previous;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Operations that (re)create the device subscription on the server. */
|
|
140
|
+
export function createsSubscription(operation: Operation): boolean {
|
|
141
|
+
return operation.kind === 'createUser' || operation.kind === 'login' || operation.kind === 'createSubscription';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Returns the local user id an operation is scoped to, if any. */
|
|
145
|
+
export function operationUserLocalId(operation: Operation): string | undefined {
|
|
146
|
+
return 'userLocalId' in operation ? operation.userLocalId : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function isValidOperation(value: unknown): value is Operation {
|
|
150
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
151
|
+
const kind = (value as { kind?: unknown }).kind;
|
|
152
|
+
switch (kind) {
|
|
153
|
+
case 'createUser':
|
|
154
|
+
case 'login':
|
|
155
|
+
case 'updateUser':
|
|
156
|
+
case 'setAlias':
|
|
157
|
+
case 'removeAlias':
|
|
158
|
+
case 'fetchUser':
|
|
159
|
+
case 'createSubscription':
|
|
160
|
+
return typeof (value as { userLocalId?: unknown }).userLocalId === 'string';
|
|
161
|
+
case 'syncSubscription':
|
|
162
|
+
return true;
|
|
163
|
+
default:
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ActionButton, QPushNotificationData } from '../types';
|
|
2
|
+
|
|
3
|
+
type JsonObject = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
function isObject(value: unknown): value is JsonObject {
|
|
6
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function asString(value: unknown): string | undefined {
|
|
10
|
+
if (typeof value === 'string') return value.length > 0 ? value : undefined;
|
|
11
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function asNumber(value: unknown): number | undefined {
|
|
16
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
17
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
18
|
+
const parsed = Number(value);
|
|
19
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tryParseJson(value: string): unknown {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(value);
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `data` is normally an object but tolerate a JSON string. */
|
|
33
|
+
function asDataObject(value: unknown): Record<string, unknown> | undefined {
|
|
34
|
+
if (isObject(value)) return value;
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
const parsed = tryParseJson(value);
|
|
37
|
+
if (isObject(parsed)) return parsed;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseButtons(value: unknown): ActionButton[] | undefined {
|
|
43
|
+
let list = value;
|
|
44
|
+
if (typeof list === 'string') list = tryParseJson(list);
|
|
45
|
+
if (!Array.isArray(list)) return undefined;
|
|
46
|
+
const buttons: ActionButton[] = [];
|
|
47
|
+
for (const entry of list) {
|
|
48
|
+
if (!isObject(entry)) continue;
|
|
49
|
+
const id = asString(entry.id);
|
|
50
|
+
const text = asString(entry.text);
|
|
51
|
+
if (!id || !text) continue;
|
|
52
|
+
const button: ActionButton = { id, text };
|
|
53
|
+
const icon = asString(entry.icon);
|
|
54
|
+
const url = asString(entry.url);
|
|
55
|
+
if (icon) button.icon = icon;
|
|
56
|
+
if (url) button.url = url;
|
|
57
|
+
buttons.push(button);
|
|
58
|
+
}
|
|
59
|
+
return buttons.length > 0 ? buttons : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stripUndefined<T extends object>(value: T): T {
|
|
63
|
+
const result = { ...value } as Record<string, unknown>;
|
|
64
|
+
for (const key of Object.keys(result)) {
|
|
65
|
+
if (result[key] === undefined) delete result[key];
|
|
66
|
+
}
|
|
67
|
+
return result as T;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parses the Android `data["qpush"]` JSON (already decoded) into the
|
|
72
|
+
* normalized notification shape.
|
|
73
|
+
*/
|
|
74
|
+
export function parseAndroidPayload(qpush: JsonObject, raw: JsonObject = qpush): QPushNotificationData | null {
|
|
75
|
+
const notificationId = asString(qpush.id);
|
|
76
|
+
if (!notificationId) return null;
|
|
77
|
+
return stripUndefined<QPushNotificationData>({
|
|
78
|
+
notificationId,
|
|
79
|
+
title: asString(qpush.title),
|
|
80
|
+
body: asString(qpush.body),
|
|
81
|
+
launchURL: asString(qpush.url),
|
|
82
|
+
additionalData: asDataObject(qpush.data),
|
|
83
|
+
bigPicture: asString(qpush.image),
|
|
84
|
+
largeIcon: asString(qpush.large_icon),
|
|
85
|
+
smallIcon: asString(qpush.small_icon),
|
|
86
|
+
accentColor: asString(qpush.accent_color),
|
|
87
|
+
sound: asString(qpush.sound),
|
|
88
|
+
channelId: asString(qpush.channel_id),
|
|
89
|
+
priority: asNumber(qpush.priority),
|
|
90
|
+
groupKey: asString(qpush.group),
|
|
91
|
+
collapseId: asString(qpush.collapse_id),
|
|
92
|
+
visibility: asNumber(qpush.visibility),
|
|
93
|
+
actionButtons: parseButtons(qpush.buttons),
|
|
94
|
+
rawPayload: raw,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Parses an iOS `userInfo` dictionary (`aps` + `qpush`). */
|
|
99
|
+
export function parseIosPayload(userInfo: JsonObject): QPushNotificationData | null {
|
|
100
|
+
let qpush: unknown = userInfo.qpush;
|
|
101
|
+
if (typeof qpush === 'string') qpush = tryParseJson(qpush);
|
|
102
|
+
if (!isObject(qpush)) return null;
|
|
103
|
+
const notificationId = asString(qpush.id);
|
|
104
|
+
if (!notificationId) return null;
|
|
105
|
+
|
|
106
|
+
const aps = isObject(userInfo.aps) ? userInfo.aps : {};
|
|
107
|
+
let title: string | undefined;
|
|
108
|
+
let subtitle: string | undefined;
|
|
109
|
+
let body: string | undefined;
|
|
110
|
+
const alert = aps.alert;
|
|
111
|
+
if (typeof alert === 'string') {
|
|
112
|
+
body = asString(alert);
|
|
113
|
+
} else if (isObject(alert)) {
|
|
114
|
+
title = asString(alert.title);
|
|
115
|
+
subtitle = asString(alert.subtitle);
|
|
116
|
+
body = asString(alert.body);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let sound: string | undefined;
|
|
120
|
+
if (typeof aps.sound === 'string') sound = asString(aps.sound);
|
|
121
|
+
else if (isObject(aps.sound)) sound = asString(aps.sound.name);
|
|
122
|
+
|
|
123
|
+
const contentAvailable = asNumber(aps['content-available']);
|
|
124
|
+
|
|
125
|
+
return stripUndefined<QPushNotificationData>({
|
|
126
|
+
notificationId,
|
|
127
|
+
title,
|
|
128
|
+
subtitle,
|
|
129
|
+
body,
|
|
130
|
+
launchURL: asString(qpush.url),
|
|
131
|
+
additionalData: asDataObject(qpush.data),
|
|
132
|
+
bigPicture: asString(qpush.image),
|
|
133
|
+
sound,
|
|
134
|
+
badge: asNumber(aps.badge),
|
|
135
|
+
category: asString(aps.category),
|
|
136
|
+
groupKey: asString(aps['thread-id']),
|
|
137
|
+
interruptionLevel: asString(aps['interruption-level']),
|
|
138
|
+
relevanceScore: asNumber(aps['relevance-score']),
|
|
139
|
+
contentAvailable: contentAvailable === 1 ? true : undefined,
|
|
140
|
+
actionButtons: parseButtons(qpush.buttons),
|
|
141
|
+
rawPayload: userInfo,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parses whatever the native layer forwarded:
|
|
147
|
+
* - Android: the `data["qpush"]` JSON string, the decoded object, or the
|
|
148
|
+
* whole FCM data map (`{ qpush: "<json>" }`)
|
|
149
|
+
* - iOS: the notification `userInfo` (`{ aps, qpush }`)
|
|
150
|
+
*
|
|
151
|
+
* Returns `null` when the payload is not a QPush notification.
|
|
152
|
+
*/
|
|
153
|
+
export function parseNotificationPayload(raw: unknown): QPushNotificationData | null {
|
|
154
|
+
let value: unknown = raw;
|
|
155
|
+
if (typeof value === 'string') value = tryParseJson(value);
|
|
156
|
+
if (!isObject(value)) return null;
|
|
157
|
+
|
|
158
|
+
// iOS userInfo (always carries `aps`).
|
|
159
|
+
if (isObject(value.aps)) return parseIosPayload(value);
|
|
160
|
+
|
|
161
|
+
// A data map whose `qpush` entry was already decoded. The iOS `qpush`
|
|
162
|
+
// keys (id/url/data/image/buttons) are a subset of the Android ones.
|
|
163
|
+
if (isObject(value.qpush)) return parseAndroidPayload(value.qpush, value);
|
|
164
|
+
|
|
165
|
+
// Android FCM data map: { qpush: "<json string>" }
|
|
166
|
+
if (typeof value.qpush === 'string') {
|
|
167
|
+
const inner = tryParseJson(value.qpush);
|
|
168
|
+
if (!isObject(inner)) return null;
|
|
169
|
+
return parseAndroidPayload(inner, inner);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Android `qpush` object itself.
|
|
173
|
+
if ('id' in value) return parseAndroidPayload(value);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { FcmParams, PermissionStatus } from '../types';
|
|
2
|
+
import type { KeyValueStore } from './storage';
|
|
3
|
+
import type { DeviceInfo } from './subscription';
|
|
4
|
+
|
|
5
|
+
export interface Scheduler {
|
|
6
|
+
setTimeout(callback: () => void, ms: number): unknown;
|
|
7
|
+
clearTimeout(handle: unknown): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Subscription {
|
|
11
|
+
remove(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FetchResponseLike {
|
|
15
|
+
status: number;
|
|
16
|
+
headers: { get(name: string): string | null };
|
|
17
|
+
text(): Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FetchInit {
|
|
21
|
+
method: string;
|
|
22
|
+
headers: Record<string, string>;
|
|
23
|
+
body?: string;
|
|
24
|
+
signal?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FetchLike = (url: string, init: FetchInit) => Promise<FetchResponseLike>;
|
|
28
|
+
|
|
29
|
+
export interface AppStateLike {
|
|
30
|
+
readonly currentState: string | null | undefined;
|
|
31
|
+
addEventListener(type: 'change', listener: (state: string) => void): Subscription;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NativeEventSource {
|
|
35
|
+
addListener(eventName: string, listener: (payload: any) => void): Subscription;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Result of `registerForPush`. */
|
|
39
|
+
export interface NativePushRegistration {
|
|
40
|
+
token: string | null;
|
|
41
|
+
/** Negative `notification_types` value when registration failed. */
|
|
42
|
+
notificationTypes?: number;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Typed facade over the native module (see `NativeQPushPush.ts`). */
|
|
47
|
+
export interface NativeBridge extends KeyValueStore {
|
|
48
|
+
getDeviceInfo(): Promise<DeviceInfo>;
|
|
49
|
+
registerForPush(fcmParams: FcmParams | null): Promise<NativePushRegistration>;
|
|
50
|
+
getPermissionStatus(): Promise<PermissionStatus>;
|
|
51
|
+
requestPermission(fallbackToSettings: boolean): Promise<boolean>;
|
|
52
|
+
registerForProvisionalAuthorization(): Promise<boolean>;
|
|
53
|
+
canRequestPermission(): Promise<boolean>;
|
|
54
|
+
clearAll(): void;
|
|
55
|
+
removeNotification(notificationId: string): void;
|
|
56
|
+
removeGroupedNotifications(groupKey: string): void;
|
|
57
|
+
/** Clears the app badge and returns the previous value (iOS; 0 on Android). */
|
|
58
|
+
clearBadge(): Promise<number>;
|
|
59
|
+
/** Tells native that JS subscribed to events: queued native events are flushed. */
|
|
60
|
+
setJsReady(): void;
|
|
61
|
+
displayForegroundNotification(displayId: string): void;
|
|
62
|
+
preventForegroundDisplay(displayId: string): void;
|
|
63
|
+
setAutoOpenUrls(enabled: boolean): void;
|
|
64
|
+
setLogLevel(level: number): void;
|
|
65
|
+
beginBackgroundTask(): Promise<number>;
|
|
66
|
+
endBackgroundTask(taskId: number): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Everything the core needs from the host environment (injected for tests). */
|
|
70
|
+
export interface PlatformAdapter {
|
|
71
|
+
os: 'ios' | 'android';
|
|
72
|
+
native: NativeBridge;
|
|
73
|
+
nativeEvents: NativeEventSource;
|
|
74
|
+
appState: AppStateLike;
|
|
75
|
+
fetch: FetchLike;
|
|
76
|
+
scheduler: Scheduler;
|
|
77
|
+
now(): number;
|
|
78
|
+
random(): number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const defaultScheduler: Scheduler = {
|
|
82
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
83
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
84
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { FcmParams, PermissionStatus } from '../types';
|
|
2
|
+
import type { SubscriptionFields } from './subscription';
|
|
3
|
+
|
|
4
|
+
/** Local model of the current user. */
|
|
5
|
+
export interface PersistedUser {
|
|
6
|
+
/** Device-generated id; operations reference users by it. */
|
|
7
|
+
localId: string;
|
|
8
|
+
qpushId?: string;
|
|
9
|
+
externalId?: string;
|
|
10
|
+
/** Aliases except `qpush_id` / `onesignal_id` (includes `external_id`). */
|
|
11
|
+
aliases: Record<string, string>;
|
|
12
|
+
tags: Record<string, string>;
|
|
13
|
+
language?: string;
|
|
14
|
+
/** `true` once the developer called `setLanguage()`. */
|
|
15
|
+
languageExplicit?: boolean;
|
|
16
|
+
/** Last timezone sent to the server. */
|
|
17
|
+
timezone?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Local model of this device's push subscription. */
|
|
21
|
+
export interface PersistedSubscription {
|
|
22
|
+
id?: string;
|
|
23
|
+
token?: string;
|
|
24
|
+
/** Negative notification_types from a failed native registration. */
|
|
25
|
+
tokenError?: number;
|
|
26
|
+
/** Developer intent: `optIn()` / `optOut()`. */
|
|
27
|
+
optedIn: boolean;
|
|
28
|
+
permission: PermissionStatus;
|
|
29
|
+
/** Fields last acknowledged by the server. */
|
|
30
|
+
synced?: Partial<SubscriptionFields>;
|
|
31
|
+
/** Send `badge_count: 0` with the next subscription PATCH. */
|
|
32
|
+
badgeResetPending?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PersistedConfig {
|
|
36
|
+
appId: string;
|
|
37
|
+
serverUrl: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PersistedState {
|
|
41
|
+
version: 1;
|
|
42
|
+
config?: PersistedConfig;
|
|
43
|
+
user: PersistedUser;
|
|
44
|
+
subscription: PersistedSubscription;
|
|
45
|
+
/** local user id -> qpush_id, for users referenced by pending operations. */
|
|
46
|
+
idMap: Record<string, string>;
|
|
47
|
+
consentRequired: boolean;
|
|
48
|
+
consentGiven: boolean;
|
|
49
|
+
/** Last FCM params from android_params.js (`null` = server has none). */
|
|
50
|
+
fcmParams?: FcmParams | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createUser(localId: string): PersistedUser {
|
|
54
|
+
return { localId, aliases: {}, tags: {} };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createDefaultState(localId: string): PersistedState {
|
|
58
|
+
return {
|
|
59
|
+
version: 1,
|
|
60
|
+
user: createUser(localId),
|
|
61
|
+
subscription: { optedIn: true, permission: 'notDetermined' },
|
|
62
|
+
idMap: {},
|
|
63
|
+
consentRequired: false,
|
|
64
|
+
consentGiven: false,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isRecordOfStrings(value: unknown): value is Record<string, string> {
|
|
69
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
70
|
+
return Object.values(value as Record<string, unknown>).every((entry) => typeof entry === 'string');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PERMISSIONS: PermissionStatus[] = ['notDetermined', 'denied', 'granted', 'provisional', 'ephemeral'];
|
|
74
|
+
|
|
75
|
+
/** Validates persisted JSON, falling back to defaults for broken parts. */
|
|
76
|
+
export function sanitizeState(raw: unknown, newLocalId: () => string): PersistedState {
|
|
77
|
+
const fallback = createDefaultState(newLocalId());
|
|
78
|
+
if (typeof raw !== 'object' || raw === null) return fallback;
|
|
79
|
+
const value = raw as Partial<PersistedState>;
|
|
80
|
+
if (value.version !== 1) return fallback;
|
|
81
|
+
|
|
82
|
+
const state: PersistedState = { ...fallback };
|
|
83
|
+
if (
|
|
84
|
+
value.config &&
|
|
85
|
+
typeof value.config.appId === 'string' &&
|
|
86
|
+
typeof value.config.serverUrl === 'string'
|
|
87
|
+
) {
|
|
88
|
+
state.config = { appId: value.config.appId, serverUrl: value.config.serverUrl };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const user = value.user;
|
|
92
|
+
if (user && typeof user.localId === 'string') {
|
|
93
|
+
state.user = {
|
|
94
|
+
localId: user.localId,
|
|
95
|
+
qpushId: typeof user.qpushId === 'string' ? user.qpushId : undefined,
|
|
96
|
+
externalId: typeof user.externalId === 'string' ? user.externalId : undefined,
|
|
97
|
+
aliases: isRecordOfStrings(user.aliases) ? user.aliases : {},
|
|
98
|
+
tags: isRecordOfStrings(user.tags) ? user.tags : {},
|
|
99
|
+
language: typeof user.language === 'string' ? user.language : undefined,
|
|
100
|
+
languageExplicit: user.languageExplicit === true,
|
|
101
|
+
timezone: typeof user.timezone === 'string' ? user.timezone : undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const subscription = value.subscription;
|
|
106
|
+
if (subscription && typeof subscription === 'object') {
|
|
107
|
+
state.subscription = {
|
|
108
|
+
id: typeof subscription.id === 'string' ? subscription.id : undefined,
|
|
109
|
+
token: typeof subscription.token === 'string' ? subscription.token : undefined,
|
|
110
|
+
tokenError: typeof subscription.tokenError === 'number' ? subscription.tokenError : undefined,
|
|
111
|
+
optedIn: subscription.optedIn !== false,
|
|
112
|
+
permission: PERMISSIONS.includes(subscription.permission as PermissionStatus)
|
|
113
|
+
? (subscription.permission as PermissionStatus)
|
|
114
|
+
: 'notDetermined',
|
|
115
|
+
synced:
|
|
116
|
+
typeof subscription.synced === 'object' && subscription.synced !== null ? subscription.synced : undefined,
|
|
117
|
+
badgeResetPending: subscription.badgeResetPending === true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
state.idMap = isRecordOfStrings(value.idMap) ? value.idMap : {};
|
|
122
|
+
state.consentRequired = value.consentRequired === true;
|
|
123
|
+
state.consentGiven = value.consentGiven === true;
|
|
124
|
+
if (value.fcmParams === null) state.fcmParams = null;
|
|
125
|
+
else if (value.fcmParams && typeof value.fcmParams === 'object') state.fcmParams = value.fcmParams;
|
|
126
|
+
return state;
|
|
127
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal async key/value store. In the app it is backed by the native module
|
|
3
|
+
* (SharedPreferences on Android, UserDefaults on iOS) so native code can read
|
|
4
|
+
* the same values; tests use {@link MemoryStore}.
|
|
5
|
+
*/
|
|
6
|
+
export interface KeyValueStore {
|
|
7
|
+
getItem(key: string): Promise<string | null>;
|
|
8
|
+
setItem(key: string, value: string): Promise<void>;
|
|
9
|
+
removeItem(key: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class MemoryStore implements KeyValueStore {
|
|
13
|
+
readonly data = new Map<string, string>();
|
|
14
|
+
|
|
15
|
+
async getItem(key: string): Promise<string | null> {
|
|
16
|
+
return this.data.has(key) ? (this.data.get(key) as string) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async setItem(key: string, value: string): Promise<void> {
|
|
20
|
+
this.data.set(key, value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async removeItem(key: string): Promise<void> {
|
|
24
|
+
this.data.delete(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reads and parses a JSON value, returning `null` on any failure. */
|
|
29
|
+
export async function readJson<T>(store: KeyValueStore, key: string): Promise<T | null> {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await store.getItem(key);
|
|
32
|
+
if (raw == null || raw === '') return null;
|
|
33
|
+
return JSON.parse(raw) as T;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Serializes writes for one key: only the latest value matters, writes never
|
|
41
|
+
* overlap and a failed write does not break the chain.
|
|
42
|
+
*/
|
|
43
|
+
export class SerialWriter {
|
|
44
|
+
private chain: Promise<void> = Promise.resolve();
|
|
45
|
+
private latest: string | null = null;
|
|
46
|
+
private scheduled = false;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly store: KeyValueStore,
|
|
50
|
+
private readonly key: string,
|
|
51
|
+
private readonly onError: (error: unknown) => void = () => {},
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
write(value: string): Promise<void> {
|
|
55
|
+
this.latest = value;
|
|
56
|
+
if (!this.scheduled) {
|
|
57
|
+
this.scheduled = true;
|
|
58
|
+
this.chain = this.chain
|
|
59
|
+
.then(async () => {
|
|
60
|
+
this.scheduled = false;
|
|
61
|
+
const toWrite = this.latest;
|
|
62
|
+
if (toWrite !== null) await this.store.setItem(this.key, toWrite);
|
|
63
|
+
})
|
|
64
|
+
.catch((error) => {
|
|
65
|
+
this.scheduled = false;
|
|
66
|
+
this.onError(error);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return this.chain;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolves when every pending write finished. */
|
|
73
|
+
flush(): Promise<void> {
|
|
74
|
+
return this.chain;
|
|
75
|
+
}
|
|
76
|
+
}
|