@goliapkg/sentori-react-native 2.1.0 → 3.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/android/build.gradle +12 -0
- package/android/src/main/AndroidManifest.xml +24 -1
- package/android/src/main/java/com/sentori/SentoriFirebaseMessagingService.kt +56 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +43 -0
- package/android/src/main/java/com/sentori/SentoriPushNotifications.kt +293 -0
- package/ios/SentoriModule.swift +39 -0
- package/ios/SentoriPushNotifications.swift +303 -0
- package/lib/capture.d.ts +6 -1
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +49 -2
- package/lib/capture.js.map +1 -1
- package/lib/compat/sentry.d.ts +14 -0
- package/lib/compat/sentry.d.ts.map +1 -1
- package/lib/compat/sentry.js +6 -0
- package/lib/compat/sentry.js.map +1 -1
- package/lib/config.d.ts +12 -18
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/expo-compat.d.ts +270 -0
- package/lib/expo-compat.d.ts.map +1 -0
- package/lib/expo-compat.js +500 -0
- package/lib/expo-compat.js.map +1 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +7 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +3 -0
- package/lib/init.js.map +1 -1
- package/lib/native.d.ts +17 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +57 -0
- package/lib/native.js.map +1 -1
- package/lib/push.d.ts +58 -0
- package/lib/push.d.ts.map +1 -0
- package/lib/push.js +294 -0
- package/lib/push.js.map +1 -0
- package/package.json +9 -5
- package/src/__tests__/before-send.test.ts +72 -0
- package/src/__tests__/compat-sentry.test.ts +121 -0
- package/src/__tests__/push.test.ts +178 -0
- package/src/capture.ts +48 -2
- package/src/compat/sentry.ts +8 -0
- package/src/config.ts +12 -15
- package/src/expo-compat.ts +698 -0
- package/src/index.ts +40 -0
- package/src/init.ts +10 -0
- package/src/native.ts +102 -0
- package/src/push.ts +382 -0
package/src/index.ts
CHANGED
|
@@ -121,6 +121,20 @@ export type {
|
|
|
121
121
|
CaptureMessageOptions,
|
|
122
122
|
MessageLevel,
|
|
123
123
|
} from '@goliapkg/sentori-core';
|
|
124
|
+
/** v2.3 — logger surface re-exported from core so hosts can
|
|
125
|
+
* `import { setLogLevel } from '@goliapkg/sentori-react-native'`
|
|
126
|
+
* per design §3 ("Production override"). `setLogTransport` lets
|
|
127
|
+
* hosts route Sentori-internal lines into their own log
|
|
128
|
+
* aggregator (Datadog, etc.). */
|
|
129
|
+
export {
|
|
130
|
+
getLogLevel,
|
|
131
|
+
type LogLevel,
|
|
132
|
+
logger,
|
|
133
|
+
type LogTransport,
|
|
134
|
+
setLogLevel,
|
|
135
|
+
setLogTransport,
|
|
136
|
+
} from '@goliapkg/sentori-core';
|
|
137
|
+
export type { ReadyInfo } from './config';
|
|
124
138
|
export { ErrorBoundary } from './error-boundary';
|
|
125
139
|
export { FeedbackButton, type FeedbackButtonHandle, type FeedbackButtonProps } from './feedback-widget';
|
|
126
140
|
export {
|
|
@@ -173,6 +187,32 @@ export {
|
|
|
173
187
|
} from './session-tracker';
|
|
174
188
|
export { type NavigationRefLike, useTraceNavigation } from './navigation';
|
|
175
189
|
|
|
190
|
+
// v2.9 — Push notifications (iOS this release; v2.10 lights Android).
|
|
191
|
+
// Surfaced as a `sentori.push` sub-namespace from the default barrel.
|
|
192
|
+
// Opt-in: `sentori.push.register({...})` triggers the OS permission
|
|
193
|
+
// prompt. Sentori never prompts on its own.
|
|
194
|
+
import * as _push from './push';
|
|
195
|
+
export const push = {
|
|
196
|
+
register: _push.register,
|
|
197
|
+
unregister: _push.unregister,
|
|
198
|
+
getCachedIpt: _push.getCachedIpt,
|
|
199
|
+
getStatus: _push.getStatus,
|
|
200
|
+
requestPermission: _push.requestPermission,
|
|
201
|
+
};
|
|
202
|
+
export type {
|
|
203
|
+
PushRegisterOptions,
|
|
204
|
+
PushRegisterResult,
|
|
205
|
+
PushNotificationPayload,
|
|
206
|
+
} from './push';
|
|
207
|
+
export type {
|
|
208
|
+
PushMessage,
|
|
209
|
+
PushOptions,
|
|
210
|
+
PushPriority,
|
|
211
|
+
PushReceipt,
|
|
212
|
+
PushTicket,
|
|
213
|
+
PushTicketStatus,
|
|
214
|
+
} from '@goliapkg/sentori-core';
|
|
215
|
+
|
|
176
216
|
export type {
|
|
177
217
|
Event,
|
|
178
218
|
SentoriError,
|
package/src/init.ts
CHANGED
|
@@ -176,6 +176,13 @@ export type InitOptions = {
|
|
|
176
176
|
* SDK is live instead of scanning the console. The `ReadyInfo`
|
|
177
177
|
* carries native-module bind status + cold-start timing. */
|
|
178
178
|
onReady?: (info: ReadyInfo) => void;
|
|
179
|
+
/** v2.3 — mutate-or-drop hook on each outbound event (sync).
|
|
180
|
+
* Return the event to ship it (possibly mutated), or `null` to
|
|
181
|
+
* drop. See `BeforeSendHook` for the throwing / non-event
|
|
182
|
+
* fallback policy. Used for host-side PII scrubbing the SDK
|
|
183
|
+
* can't do automatically; server-side privacy_lab still runs
|
|
184
|
+
* regardless. */
|
|
185
|
+
beforeSend?: import('@goliapkg/sentori-core').BeforeSendHook;
|
|
179
186
|
};
|
|
180
187
|
|
|
181
188
|
const DEFAULT_INGEST_URL = 'https://ingest.sentori.golia.jp';
|
|
@@ -228,6 +235,9 @@ export const init = (options: InitOptions): void => {
|
|
|
228
235
|
// carries the customer journey. Defaults false to preserve v1
|
|
229
236
|
// breadcrumb shape on upgrade.
|
|
230
237
|
trackAutoBreadcrumb: options.capture?.trackAutoBreadcrumb === true,
|
|
238
|
+
// v2.3 — host-side beforeSend hook (sync). Stored on Config so
|
|
239
|
+
// capture.ts can pull it without re-resolving the InitOptions.
|
|
240
|
+
beforeSend: options.beforeSend,
|
|
231
241
|
});
|
|
232
242
|
|
|
233
243
|
// Tell the native crash handler about the config so the JSON it writes
|
package/src/native.ts
CHANGED
|
@@ -123,6 +123,45 @@ type SentoriNativeModule = {
|
|
|
123
123
|
stopAnrWatchdog?: () => void
|
|
124
124
|
/** Dev-only — example app uses this to verify the crash flow. */
|
|
125
125
|
triggerTestNativeCrash?: () => void
|
|
126
|
+
/**
|
|
127
|
+
* v2.9 — current iOS push permission status without prompting.
|
|
128
|
+
* Returns `granted` / `denied` / `notDetermined` / `provisional`
|
|
129
|
+
* / `ephemeral` (iOS-specific) mirroring web `Notification.permission`.
|
|
130
|
+
*/
|
|
131
|
+
pushGetStatus?: () => Promise<string>
|
|
132
|
+
/**
|
|
133
|
+
* v2.9 — triggers the OS permission prompt the first time, or
|
|
134
|
+
* returns the cached decision. Same return shape as
|
|
135
|
+
* `pushGetStatus`.
|
|
136
|
+
*/
|
|
137
|
+
pushRequestPermission?: () => Promise<string>
|
|
138
|
+
/**
|
|
139
|
+
* v2.9 — kicks off `UIApplication.registerForRemoteNotifications`.
|
|
140
|
+
* The token comes back asynchronously via the AppDelegate swizzle
|
|
141
|
+
* and lands in the native buffer — JS retrieves it through
|
|
142
|
+
* `pushDrainState`.
|
|
143
|
+
*/
|
|
144
|
+
pushRegister?: () => void
|
|
145
|
+
/**
|
|
146
|
+
* v2.9 — counterpart to `pushRegister`. Calls
|
|
147
|
+
* `UIApplication.unregisterForRemoteNotifications` + clears the
|
|
148
|
+
* cached token. The server-side DELETE is the JS layer's
|
|
149
|
+
* responsibility.
|
|
150
|
+
*/
|
|
151
|
+
pushUnregister?: () => void
|
|
152
|
+
/**
|
|
153
|
+
* v2.9 — snapshot the buffered token, foreground notifications,
|
|
154
|
+
* and tap responses, and clear the buffers atomically. Returns
|
|
155
|
+
* { token?: string, error?: string, notifications: [...], taps: [...] }
|
|
156
|
+
* where each notification carries
|
|
157
|
+
* { id, title, body, subtitle?, category?, userInfo, receivedAt }.
|
|
158
|
+
*/
|
|
159
|
+
pushDrainState?: () => Promise<{
|
|
160
|
+
token?: string
|
|
161
|
+
error?: string
|
|
162
|
+
notifications: Array<Record<string, unknown>>
|
|
163
|
+
taps: Array<Record<string, unknown>>
|
|
164
|
+
}>
|
|
126
165
|
}
|
|
127
166
|
|
|
128
167
|
let _native: SentoriNativeModule | null | undefined
|
|
@@ -213,6 +252,69 @@ export function startAnrWatchdog(options?: {
|
|
|
213
252
|
}
|
|
214
253
|
}
|
|
215
254
|
|
|
255
|
+
// ── v2.9 push wrappers ──────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/** Returns the current iOS push permission status without
|
|
258
|
+
* prompting. `null` when the native module isn't available. */
|
|
259
|
+
export async function pushGetStatus(): Promise<null | string> {
|
|
260
|
+
const n = native()
|
|
261
|
+
if (!n?.pushGetStatus) return null
|
|
262
|
+
try {
|
|
263
|
+
return await n.pushGetStatus()
|
|
264
|
+
} catch {
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Prompts for permission (or returns cached decision). `null` when
|
|
270
|
+
* the native module isn't available. */
|
|
271
|
+
export async function pushRequestPermission(): Promise<null | string> {
|
|
272
|
+
const n = native()
|
|
273
|
+
if (!n?.pushRequestPermission) return null
|
|
274
|
+
try {
|
|
275
|
+
return await n.pushRequestPermission()
|
|
276
|
+
} catch {
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function pushRegister(): void {
|
|
282
|
+
try {
|
|
283
|
+
native()?.pushRegister?.()
|
|
284
|
+
} catch {
|
|
285
|
+
/* never throw */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function pushUnregister(): void {
|
|
290
|
+
try {
|
|
291
|
+
native()?.pushUnregister?.()
|
|
292
|
+
} catch {
|
|
293
|
+
/* never throw */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export type PushDrainState = {
|
|
298
|
+
token?: string
|
|
299
|
+
error?: string
|
|
300
|
+
notifications: Array<Record<string, unknown>>
|
|
301
|
+
taps: Array<Record<string, unknown>>
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Snapshot + clear the native push buffers. Returns empty state
|
|
305
|
+
* when the native module isn't available. */
|
|
306
|
+
export async function pushDrainState(): Promise<PushDrainState> {
|
|
307
|
+
const n = native()
|
|
308
|
+
if (!n?.pushDrainState) {
|
|
309
|
+
return { notifications: [], taps: [] }
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
return await n.pushDrainState()
|
|
313
|
+
} catch {
|
|
314
|
+
return { notifications: [], taps: [] }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
216
318
|
export function stopAnrWatchdog(): void {
|
|
217
319
|
try {
|
|
218
320
|
native()?.stopAnrWatchdog?.()
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// v2.9 — React Native push notification opt-in (iOS in this release).
|
|
2
|
+
//
|
|
3
|
+
// Mirrors `@goliapkg/sentori-javascript`'s `registerWeb` ergonomics
|
|
4
|
+
// so a cross-platform host app reasons about both flows the same way.
|
|
5
|
+
//
|
|
6
|
+
// Flow:
|
|
7
|
+
// 1. `pushRequestPermission()` — OS prompt the first time, or
|
|
8
|
+
// returns the cached decision.
|
|
9
|
+
// 2. `pushRegister()` — kicks off
|
|
10
|
+
// `UIApplication.registerForRemoteNotifications`. The token
|
|
11
|
+
// arrives asynchronously via the AppDelegate swizzle and lands
|
|
12
|
+
// in the native buffer.
|
|
13
|
+
// 3. Poll `pushDrainState()` at 200 ms ticks for up to 8 s waiting
|
|
14
|
+
// for the token.
|
|
15
|
+
// 4. POST `/v1/push/tokens` with
|
|
16
|
+
// `provider: 'apns'`, `env: __DEV__ ? 'sandbox' : 'production'`,
|
|
17
|
+
// `nativeToken: <hex>`, `linkHash?`, `metadata`.
|
|
18
|
+
// 5. Cache the returned `ipt_*` handle (AsyncStorage when
|
|
19
|
+
// available, otherwise module-scoped).
|
|
20
|
+
// 6. Start a 1 Hz drain loop that fires `onMessage` / `onTap` from
|
|
21
|
+
// buffered events while the app is foreground. Pauses on
|
|
22
|
+
// background, resumes on active, per the perf iron rule.
|
|
23
|
+
|
|
24
|
+
import { logger } from '@goliapkg/sentori-core'
|
|
25
|
+
// AppState is RN-only; we treat it dynamically so the SDK keeps
|
|
26
|
+
// importing cleanly under Bun / web.
|
|
27
|
+
type AppStateModule = {
|
|
28
|
+
currentState: string
|
|
29
|
+
addEventListener: (
|
|
30
|
+
type: 'change',
|
|
31
|
+
listener: (state: string) => void,
|
|
32
|
+
) => { remove: () => void }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
pushDrainState,
|
|
37
|
+
pushGetStatus,
|
|
38
|
+
pushRegister as nativePushRegister,
|
|
39
|
+
pushRequestPermission,
|
|
40
|
+
pushUnregister as nativePushUnregister,
|
|
41
|
+
} from './native.js'
|
|
42
|
+
|
|
43
|
+
const STORAGE_KEY = 'sentori.push.ipt'
|
|
44
|
+
|
|
45
|
+
let _cachedIpt: null | string = null
|
|
46
|
+
let _drainInterval: ReturnType<typeof setInterval> | null = null
|
|
47
|
+
let _appStateSubscription: { remove: () => void } | null = null
|
|
48
|
+
let _backgrounded = false
|
|
49
|
+
|
|
50
|
+
let _onMessage: PushRegisterOptions['onMessage'] = undefined
|
|
51
|
+
let _onTap: PushRegisterOptions['onTap'] = undefined
|
|
52
|
+
|
|
53
|
+
export type PushRegisterOptions = {
|
|
54
|
+
/** Identity-link hash. Pass `hashIdentities({ email }).email` if
|
|
55
|
+
* the host has run the v2.3 identity flow. Lets the server-side
|
|
56
|
+
* push routing target a specific user across all their devices. */
|
|
57
|
+
linkHash?: string
|
|
58
|
+
/** Extra metadata to attach to the device_tokens row (e.g. app
|
|
59
|
+
* version, locale). Optional. */
|
|
60
|
+
metadata?: Record<string, unknown>
|
|
61
|
+
/** Foreground notification arrival. Fires once per notification
|
|
62
|
+
* the SW or iOS native delegate hands us. */
|
|
63
|
+
onMessage?: (payload: PushNotificationPayload) => void
|
|
64
|
+
/** User tapped a notification. Fires once per tap. */
|
|
65
|
+
onTap?: (data: unknown) => void
|
|
66
|
+
/** Token registration completed — useful when the host wants the
|
|
67
|
+
* ipt handle in real time without awaiting `register()`. */
|
|
68
|
+
onToken?: (ipt: string) => void
|
|
69
|
+
/** Any failure in the registration flow. The promise also
|
|
70
|
+
* rejects; this callback is convenience. */
|
|
71
|
+
onError?: (err: Error) => void
|
|
72
|
+
/** Override the timeout when waiting for the native token to
|
|
73
|
+
* arrive after `registerForRemoteNotifications`. Defaults to
|
|
74
|
+
* 8000 ms; bump on slow networks / TestFlight provisioning
|
|
75
|
+
* delays. */
|
|
76
|
+
tokenTimeoutMs?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type PushRegisterResult = {
|
|
80
|
+
/** Stable device handle (`ipt_<uuid>`). */
|
|
81
|
+
ipt: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type PushNotificationPayload = {
|
|
85
|
+
id?: string
|
|
86
|
+
title?: string
|
|
87
|
+
body?: string
|
|
88
|
+
subtitle?: string
|
|
89
|
+
category?: string
|
|
90
|
+
userInfo?: Record<string, unknown>
|
|
91
|
+
receivedAt?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run the iOS push opt-in flow. Returns the cached `ipt_*` handle
|
|
96
|
+
* on subsequent calls when permission is still granted.
|
|
97
|
+
*/
|
|
98
|
+
export async function register(opts: PushRegisterOptions = {}): Promise<PushRegisterResult> {
|
|
99
|
+
try {
|
|
100
|
+
const cfg = getRuntimeConfig()
|
|
101
|
+
// Bind callbacks up front so the buffer drain inside
|
|
102
|
+
// waitForToken can fire onMessage / onTap for events that arrive
|
|
103
|
+
// alongside or before the device token (e.g. user taps a push
|
|
104
|
+
// received during a previous launch — iOS replays it on
|
|
105
|
+
// delegate attach).
|
|
106
|
+
_onMessage = opts.onMessage
|
|
107
|
+
_onTap = opts.onTap
|
|
108
|
+
const status = await pushRequestPermission()
|
|
109
|
+
if (status !== 'granted' && status !== 'provisional' && status !== 'ephemeral') {
|
|
110
|
+
throw new Error(`Push permission '${status ?? 'unavailable'}'; cannot register`)
|
|
111
|
+
}
|
|
112
|
+
nativePushRegister()
|
|
113
|
+
const token = await waitForToken(opts.tokenTimeoutMs ?? 8000)
|
|
114
|
+
const ipt = await registerWithServer(cfg, token, opts)
|
|
115
|
+
_cachedIpt = ipt
|
|
116
|
+
void persistIpt(ipt)
|
|
117
|
+
opts.onToken?.(ipt)
|
|
118
|
+
bindBufferDrain(opts.onMessage, opts.onTap)
|
|
119
|
+
return { ipt }
|
|
120
|
+
} catch (e) {
|
|
121
|
+
const err = e instanceof Error ? e : new Error(String(e))
|
|
122
|
+
logger.warn('push', 'register failed:', err.message)
|
|
123
|
+
opts.onError?.(err)
|
|
124
|
+
throw err
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Revoke the cached handle (DELETE /v1/push/tokens/{ipt}) +
|
|
130
|
+
* unregister locally. Idempotent — repeat calls are no-ops.
|
|
131
|
+
*/
|
|
132
|
+
export async function unregister(): Promise<void> {
|
|
133
|
+
const cfg = tryGetRuntimeConfig()
|
|
134
|
+
const ipt = _cachedIpt ?? (await readPersistedIpt())
|
|
135
|
+
if (cfg && ipt) {
|
|
136
|
+
try {
|
|
137
|
+
await fetch(joinUrl(cfg.ingestUrl, `/v1/push/tokens/${ipt}`), {
|
|
138
|
+
method: 'DELETE',
|
|
139
|
+
headers: { authorization: `Bearer ${cfg.token}` },
|
|
140
|
+
})
|
|
141
|
+
} catch (e) {
|
|
142
|
+
logger.warn('push', 'unregister server delete failed', e)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
nativePushUnregister()
|
|
146
|
+
_cachedIpt = null
|
|
147
|
+
void clearPersistedIpt()
|
|
148
|
+
teardownBufferDrain()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Returns the cached handle without hitting the network. Useful
|
|
152
|
+
* for skipping a re-register prompt across cold starts. */
|
|
153
|
+
export function getCachedIpt(): null | string {
|
|
154
|
+
return _cachedIpt
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Public re-export of the no-prompt status check. */
|
|
158
|
+
export { pushGetStatus as getStatus, pushRequestPermission as requestPermission }
|
|
159
|
+
|
|
160
|
+
// ── helpers ────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
type RuntimeConfig = { ingestUrl: string; token: string }
|
|
163
|
+
|
|
164
|
+
function getRuntimeConfig(): RuntimeConfig {
|
|
165
|
+
const cfg = tryGetRuntimeConfig()
|
|
166
|
+
if (!cfg) {
|
|
167
|
+
throw new Error('sentori is not initialised; call sentori.init() first')
|
|
168
|
+
}
|
|
169
|
+
return cfg
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function tryGetRuntimeConfig(): RuntimeConfig | null {
|
|
173
|
+
// Dynamic require avoids a circular import — `./init` already
|
|
174
|
+
// depends on `./push` via the top-level barrel re-export.
|
|
175
|
+
try {
|
|
176
|
+
const conf = require('./config.js') as { getConfig?: () => null | RuntimeConfig }
|
|
177
|
+
return conf.getConfig?.() ?? null
|
|
178
|
+
} catch {
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function waitForToken(timeoutMs: number): Promise<string> {
|
|
184
|
+
const start = Date.now()
|
|
185
|
+
while (Date.now() - start < timeoutMs) {
|
|
186
|
+
const state = await pushDrainState()
|
|
187
|
+
if (state.error) {
|
|
188
|
+
throw new Error(`APNs registration failed: ${state.error}`)
|
|
189
|
+
}
|
|
190
|
+
if (state.token) {
|
|
191
|
+
// Push any buffered events that arrived alongside the token
|
|
192
|
+
// straight back into the registered listeners (if any).
|
|
193
|
+
flushBuffered(state.notifications, state.taps)
|
|
194
|
+
return state.token
|
|
195
|
+
}
|
|
196
|
+
flushBuffered(state.notifications, state.taps)
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`APNs token not received within ${timeoutMs} ms`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function registerWithServer(
|
|
203
|
+
cfg: RuntimeConfig,
|
|
204
|
+
nativeToken: string,
|
|
205
|
+
opts: PushRegisterOptions,
|
|
206
|
+
): Promise<string> {
|
|
207
|
+
// v2.10 — cross-platform. iOS routes via APNs with a
|
|
208
|
+
// sandbox/production env; Android routes via FCM with no env
|
|
209
|
+
// (FCM is a single host). Default to 'apns' when Platform.OS
|
|
210
|
+
// isn't detectable (e.g. unit tests).
|
|
211
|
+
const platform = detectPlatform()
|
|
212
|
+
const isAndroid = platform === 'android'
|
|
213
|
+
const env = isAndroid
|
|
214
|
+
? undefined
|
|
215
|
+
: typeof __DEV__ !== 'undefined' && __DEV__
|
|
216
|
+
? 'sandbox'
|
|
217
|
+
: 'production'
|
|
218
|
+
const body: Record<string, unknown> = {
|
|
219
|
+
provider: isAndroid ? 'fcm' : 'apns',
|
|
220
|
+
nativeToken,
|
|
221
|
+
linkHash: opts.linkHash,
|
|
222
|
+
metadata: opts.metadata ?? {},
|
|
223
|
+
}
|
|
224
|
+
if (env != null) body.env = env
|
|
225
|
+
const res = await fetch(joinUrl(cfg.ingestUrl, '/v1/push/tokens'), {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: {
|
|
228
|
+
authorization: `Bearer ${cfg.token}`,
|
|
229
|
+
'content-type': 'application/json',
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify(body),
|
|
232
|
+
})
|
|
233
|
+
if (!res.ok) throw new Error(`/v1/push/tokens HTTP ${res.status}`)
|
|
234
|
+
const json = (await res.json()) as { id?: string }
|
|
235
|
+
if (typeof json.id !== 'string' || !json.id.startsWith('ipt_')) {
|
|
236
|
+
throw new Error('server did not return an ipt_* handle')
|
|
237
|
+
}
|
|
238
|
+
return json.id
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function bindBufferDrain(
|
|
242
|
+
onMessage?: PushRegisterOptions['onMessage'],
|
|
243
|
+
onTap?: PushRegisterOptions['onTap'],
|
|
244
|
+
): void {
|
|
245
|
+
_onMessage = onMessage
|
|
246
|
+
_onTap = onTap
|
|
247
|
+
teardownBufferDrain()
|
|
248
|
+
startAppStateWatch()
|
|
249
|
+
_drainInterval = setInterval(() => {
|
|
250
|
+
if (_backgrounded) return
|
|
251
|
+
void pumpOnce()
|
|
252
|
+
}, 1000)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function teardownBufferDrain(): void {
|
|
256
|
+
if (_drainInterval) {
|
|
257
|
+
clearInterval(_drainInterval)
|
|
258
|
+
_drainInterval = null
|
|
259
|
+
}
|
|
260
|
+
_appStateSubscription?.remove()
|
|
261
|
+
_appStateSubscription = null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function pumpOnce(): Promise<void> {
|
|
265
|
+
const state = await pushDrainState()
|
|
266
|
+
flushBuffered(state.notifications, state.taps)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function flushBuffered(
|
|
270
|
+
notifications: Array<Record<string, unknown>>,
|
|
271
|
+
taps: Array<Record<string, unknown>>,
|
|
272
|
+
): void {
|
|
273
|
+
if (_onMessage) {
|
|
274
|
+
for (const raw of notifications) {
|
|
275
|
+
_onMessage(coerceNotification(raw))
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (_onTap) {
|
|
279
|
+
for (const raw of taps) {
|
|
280
|
+
_onTap(raw.userInfo ?? raw)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function coerceNotification(raw: Record<string, unknown>): PushNotificationPayload {
|
|
286
|
+
return {
|
|
287
|
+
id: raw.id as string | undefined,
|
|
288
|
+
title: raw.title as string | undefined,
|
|
289
|
+
body: raw.body as string | undefined,
|
|
290
|
+
subtitle: raw.subtitle as string | undefined,
|
|
291
|
+
category: raw.category as string | undefined,
|
|
292
|
+
userInfo: raw.userInfo as Record<string, unknown> | undefined,
|
|
293
|
+
receivedAt: raw.receivedAt as number | undefined,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function startAppStateWatch(): void {
|
|
298
|
+
if (_appStateSubscription) return
|
|
299
|
+
try {
|
|
300
|
+
const rn = require('react-native') as { AppState?: AppStateModule }
|
|
301
|
+
const AppState = rn.AppState
|
|
302
|
+
if (!AppState) return
|
|
303
|
+
_backgrounded = AppState.currentState === 'background'
|
|
304
|
+
_appStateSubscription = AppState.addEventListener('change', (state: string) => {
|
|
305
|
+
_backgrounded = state === 'background'
|
|
306
|
+
})
|
|
307
|
+
} catch {
|
|
308
|
+
/* react-native unavailable (unit test) */
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function persistIpt(ipt: string): Promise<void> {
|
|
313
|
+
const storage = await tryAsyncStorage()
|
|
314
|
+
if (!storage) return
|
|
315
|
+
try {
|
|
316
|
+
await storage.setItem(STORAGE_KEY, ipt)
|
|
317
|
+
} catch (e) {
|
|
318
|
+
logger.warn('push', 'AsyncStorage.setItem failed', e)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function clearPersistedIpt(): Promise<void> {
|
|
323
|
+
const storage = await tryAsyncStorage()
|
|
324
|
+
try {
|
|
325
|
+
await storage?.removeItem(STORAGE_KEY)
|
|
326
|
+
} catch (e) {
|
|
327
|
+
logger.warn('push', 'AsyncStorage.removeItem failed', e)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function readPersistedIpt(): Promise<null | string> {
|
|
332
|
+
const storage = await tryAsyncStorage()
|
|
333
|
+
if (!storage) return null
|
|
334
|
+
try {
|
|
335
|
+
return await storage.getItem(STORAGE_KEY)
|
|
336
|
+
} catch {
|
|
337
|
+
return null
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
type AsyncStorageLike = {
|
|
342
|
+
getItem: (k: string) => Promise<null | string>
|
|
343
|
+
setItem: (k: string, v: string) => Promise<void>
|
|
344
|
+
removeItem: (k: string) => Promise<void>
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function tryAsyncStorage(): Promise<AsyncStorageLike | null> {
|
|
348
|
+
try {
|
|
349
|
+
const mod = require('@react-native-async-storage/async-storage') as {
|
|
350
|
+
default?: AsyncStorageLike
|
|
351
|
+
}
|
|
352
|
+
return mod.default ?? null
|
|
353
|
+
} catch {
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function joinUrl(base: string, path: string): string {
|
|
359
|
+
return `${base.replace(/\/+$/, '')}${path}`
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let _platformOverride: 'ios' | 'android' | 'unknown' | null = null
|
|
363
|
+
|
|
364
|
+
/** Test-only hook to override Platform.OS detection. Production
|
|
365
|
+
* code paths must not call this. */
|
|
366
|
+
export function __setPlatformForTests(p: 'ios' | 'android' | 'unknown' | null): void {
|
|
367
|
+
_platformOverride = p
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function detectPlatform(): 'ios' | 'android' | 'unknown' {
|
|
371
|
+
if (_platformOverride != null) return _platformOverride
|
|
372
|
+
try {
|
|
373
|
+
const rn = require('react-native') as { Platform?: { OS?: string } }
|
|
374
|
+
const os = rn.Platform?.OS
|
|
375
|
+
if (os === 'ios' || os === 'android') return os
|
|
376
|
+
} catch {
|
|
377
|
+
/* react-native unavailable */
|
|
378
|
+
}
|
|
379
|
+
return 'unknown'
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
declare const __DEV__: boolean | undefined
|