@goliapkg/sentori-react-native 2.2.0 → 3.1.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/src/index.ts CHANGED
@@ -187,6 +187,35 @@ export {
187
187
  } from './session-tracker';
188
188
  export { type NavigationRefLike, useTraceNavigation } from './navigation';
189
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
+ // v2.26 — let the host stamp the current session id on outgoing
202
+ // ack POSTs so v2.27 push-correlation BI can JOIN on session_id.
203
+ setSessionContext: _push.setSessionContext,
204
+ };
205
+ export type {
206
+ PushRegisterOptions,
207
+ PushRegisterResult,
208
+ PushNotificationPayload,
209
+ } from './push';
210
+ export type {
211
+ PushMessage,
212
+ PushOptions,
213
+ PushPriority,
214
+ PushReceipt,
215
+ PushTicket,
216
+ PushTicketStatus,
217
+ } from '@goliapkg/sentori-core';
218
+
190
219
  export type {
191
220
  Event,
192
221
  SentoriError,
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,480 @@
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 { addBreadcrumb, logger } from '@goliapkg/sentori-core'
25
+
26
+ import { track } from './track.js'
27
+ // AppState is RN-only; we treat it dynamically so the SDK keeps
28
+ // importing cleanly under Bun / web.
29
+ type AppStateModule = {
30
+ currentState: string
31
+ addEventListener: (
32
+ type: 'change',
33
+ listener: (state: string) => void,
34
+ ) => { remove: () => void }
35
+ }
36
+
37
+ import {
38
+ pushDrainState,
39
+ pushGetStatus,
40
+ pushRegister as nativePushRegister,
41
+ pushRequestPermission,
42
+ pushUnregister as nativePushUnregister,
43
+ } from './native.js'
44
+
45
+ const STORAGE_KEY = 'sentori.push.ipt'
46
+
47
+ let _cachedIpt: null | string = null
48
+ let _drainInterval: ReturnType<typeof setInterval> | null = null
49
+ let _appStateSubscription: { remove: () => void } | null = null
50
+ let _backgrounded = false
51
+
52
+ let _onMessage: PushRegisterOptions['onMessage'] = undefined
53
+ let _onTap: PushRegisterOptions['onTap'] = undefined
54
+
55
+ // v2.26 — confirmed delivery ack pipeline. msgIds extracted from
56
+ // received pushes are queued here and flushed to the server every
57
+ // 5 s. Server-side `push_sends.acked_at` flips from NULL to
58
+ // wall-clock on first ack. See docs/roadmap/v2.26.md.
59
+ const ACK_FLUSH_INTERVAL_MS = 5000
60
+ let _ackQueue: string[] = []
61
+ let _ackFlushInterval: ReturnType<typeof setInterval> | null = null
62
+ let _sessionId: null | string = null
63
+
64
+ export type PushRegisterOptions = {
65
+ /** Identity-link hash. Pass `hashIdentities({ email }).email` if
66
+ * the host has run the v2.3 identity flow. Lets the server-side
67
+ * push routing target a specific user across all their devices. */
68
+ linkHash?: string
69
+ /** Extra metadata to attach to the device_tokens row (e.g. app
70
+ * version, locale). Optional. */
71
+ metadata?: Record<string, unknown>
72
+ /** Foreground notification arrival. Fires once per notification
73
+ * the SW or iOS native delegate hands us. */
74
+ onMessage?: (payload: PushNotificationPayload) => void
75
+ /** User tapped a notification. Fires once per tap. */
76
+ onTap?: (data: unknown) => void
77
+ /** Token registration completed — useful when the host wants the
78
+ * ipt handle in real time without awaiting `register()`. */
79
+ onToken?: (ipt: string) => void
80
+ /** Any failure in the registration flow. The promise also
81
+ * rejects; this callback is convenience. */
82
+ onError?: (err: Error) => void
83
+ /** Override the timeout when waiting for the native token to
84
+ * arrive after `registerForRemoteNotifications`. Defaults to
85
+ * 8000 ms; bump on slow networks / TestFlight provisioning
86
+ * delays. */
87
+ tokenTimeoutMs?: number
88
+ }
89
+
90
+ export type PushRegisterResult = {
91
+ /** Stable device handle (`ipt_<uuid>`). */
92
+ ipt: string
93
+ }
94
+
95
+ export type PushNotificationPayload = {
96
+ id?: string
97
+ title?: string
98
+ body?: string
99
+ subtitle?: string
100
+ category?: string
101
+ userInfo?: Record<string, unknown>
102
+ receivedAt?: number
103
+ }
104
+
105
+ /**
106
+ * Run the iOS push opt-in flow. Returns the cached `ipt_*` handle
107
+ * on subsequent calls when permission is still granted.
108
+ */
109
+ export async function register(opts: PushRegisterOptions = {}): Promise<PushRegisterResult> {
110
+ try {
111
+ const cfg = getRuntimeConfig()
112
+ // Bind callbacks up front so the buffer drain inside
113
+ // waitForToken can fire onMessage / onTap for events that arrive
114
+ // alongside or before the device token (e.g. user taps a push
115
+ // received during a previous launch — iOS replays it on
116
+ // delegate attach).
117
+ _onMessage = opts.onMessage
118
+ _onTap = opts.onTap
119
+ const status = await pushRequestPermission()
120
+ if (status !== 'granted' && status !== 'provisional' && status !== 'ephemeral') {
121
+ throw new Error(`Push permission '${status ?? 'unavailable'}'; cannot register`)
122
+ }
123
+ nativePushRegister()
124
+ const token = await waitForToken(opts.tokenTimeoutMs ?? 8000)
125
+ const ipt = await registerWithServer(cfg, token, opts)
126
+ _cachedIpt = ipt
127
+ void persistIpt(ipt)
128
+ opts.onToken?.(ipt)
129
+ bindBufferDrain(opts.onMessage, opts.onTap)
130
+ return { ipt }
131
+ } catch (e) {
132
+ const err = e instanceof Error ? e : new Error(String(e))
133
+ logger.warn('push', 'register failed:', err.message)
134
+ opts.onError?.(err)
135
+ throw err
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Revoke the cached handle (DELETE /v1/push/tokens/{ipt}) +
141
+ * unregister locally. Idempotent — repeat calls are no-ops.
142
+ */
143
+ export async function unregister(): Promise<void> {
144
+ const cfg = tryGetRuntimeConfig()
145
+ const ipt = _cachedIpt ?? (await readPersistedIpt())
146
+ if (cfg && ipt) {
147
+ try {
148
+ await fetch(joinUrl(cfg.ingestUrl, `/v1/push/tokens/${ipt}`), {
149
+ method: 'DELETE',
150
+ headers: { authorization: `Bearer ${cfg.token}` },
151
+ })
152
+ } catch (e) {
153
+ logger.warn('push', 'unregister server delete failed', e)
154
+ }
155
+ }
156
+ nativePushUnregister()
157
+ _cachedIpt = null
158
+ void clearPersistedIpt()
159
+ teardownBufferDrain()
160
+ }
161
+
162
+ /** Returns the cached handle without hitting the network. Useful
163
+ * for skipping a re-register prompt across cold starts. */
164
+ export function getCachedIpt(): null | string {
165
+ return _cachedIpt
166
+ }
167
+
168
+ /** Public re-export of the no-prompt status check. */
169
+ export { pushGetStatus as getStatus, pushRequestPermission as requestPermission }
170
+
171
+ // ── helpers ────────────────────────────────────────────────────
172
+
173
+ type RuntimeConfig = { ingestUrl: string; token: string }
174
+
175
+ function getRuntimeConfig(): RuntimeConfig {
176
+ const cfg = tryGetRuntimeConfig()
177
+ if (!cfg) {
178
+ throw new Error('sentori is not initialised; call sentori.init() first')
179
+ }
180
+ return cfg
181
+ }
182
+
183
+ function tryGetRuntimeConfig(): RuntimeConfig | null {
184
+ // Dynamic require avoids a circular import — `./init` already
185
+ // depends on `./push` via the top-level barrel re-export.
186
+ try {
187
+ const conf = require('./config.js') as { getConfig?: () => null | RuntimeConfig }
188
+ return conf.getConfig?.() ?? null
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ async function waitForToken(timeoutMs: number): Promise<string> {
195
+ const start = Date.now()
196
+ while (Date.now() - start < timeoutMs) {
197
+ const state = await pushDrainState()
198
+ if (state.error) {
199
+ throw new Error(`APNs registration failed: ${state.error}`)
200
+ }
201
+ if (state.token) {
202
+ // Push any buffered events that arrived alongside the token
203
+ // straight back into the registered listeners (if any).
204
+ flushBuffered(state.notifications, state.taps)
205
+ return state.token
206
+ }
207
+ flushBuffered(state.notifications, state.taps)
208
+ await new Promise((resolve) => setTimeout(resolve, 200))
209
+ }
210
+ throw new Error(`APNs token not received within ${timeoutMs} ms`)
211
+ }
212
+
213
+ async function registerWithServer(
214
+ cfg: RuntimeConfig,
215
+ nativeToken: string,
216
+ opts: PushRegisterOptions,
217
+ ): Promise<string> {
218
+ // v2.10 — cross-platform. iOS routes via APNs with a
219
+ // sandbox/production env; Android routes via FCM with no env
220
+ // (FCM is a single host). Default to 'apns' when Platform.OS
221
+ // isn't detectable (e.g. unit tests).
222
+ const platform = detectPlatform()
223
+ const isAndroid = platform === 'android'
224
+ const env = isAndroid
225
+ ? undefined
226
+ : typeof __DEV__ !== 'undefined' && __DEV__
227
+ ? 'sandbox'
228
+ : 'production'
229
+ const body: Record<string, unknown> = {
230
+ provider: isAndroid ? 'fcm' : 'apns',
231
+ nativeToken,
232
+ linkHash: opts.linkHash,
233
+ metadata: opts.metadata ?? {},
234
+ }
235
+ if (env != null) body.env = env
236
+ const res = await fetch(joinUrl(cfg.ingestUrl, '/v1/push/tokens'), {
237
+ method: 'POST',
238
+ headers: {
239
+ authorization: `Bearer ${cfg.token}`,
240
+ 'content-type': 'application/json',
241
+ },
242
+ body: JSON.stringify(body),
243
+ })
244
+ if (!res.ok) throw new Error(`/v1/push/tokens HTTP ${res.status}`)
245
+ const json = (await res.json()) as { id?: string }
246
+ if (typeof json.id !== 'string' || !json.id.startsWith('ipt_')) {
247
+ throw new Error('server did not return an ipt_* handle')
248
+ }
249
+ return json.id
250
+ }
251
+
252
+ function bindBufferDrain(
253
+ onMessage?: PushRegisterOptions['onMessage'],
254
+ onTap?: PushRegisterOptions['onTap'],
255
+ ): void {
256
+ _onMessage = onMessage
257
+ _onTap = onTap
258
+ teardownBufferDrain()
259
+ startAppStateWatch()
260
+ _drainInterval = setInterval(() => {
261
+ if (_backgrounded) return
262
+ void pumpOnce()
263
+ }, 1000)
264
+ }
265
+
266
+ function teardownBufferDrain(): void {
267
+ if (_drainInterval) {
268
+ clearInterval(_drainInterval)
269
+ _drainInterval = null
270
+ }
271
+ _appStateSubscription?.remove()
272
+ _appStateSubscription = null
273
+ if (_ackFlushInterval) {
274
+ clearInterval(_ackFlushInterval)
275
+ _ackFlushInterval = null
276
+ }
277
+ _ackQueue = []
278
+ }
279
+
280
+ async function pumpOnce(): Promise<void> {
281
+ const state = await pushDrainState()
282
+ flushBuffered(state.notifications, state.taps)
283
+ }
284
+
285
+ function flushBuffered(
286
+ notifications: Array<Record<string, unknown>>,
287
+ taps: Array<Record<string, unknown>>,
288
+ ): void {
289
+ for (const raw of notifications) {
290
+ // v2.26 — Observability link-through (rule #4). If the server
291
+ // injected `_sentori.msgId` in v2.25+, drop a `push` breadcrumb,
292
+ // emit `sentori.push.received` track, and queue the ack.
293
+ autoCorrelate(raw, 'received')
294
+ _onMessage?.(coerceNotification(raw))
295
+ }
296
+ for (const raw of taps) {
297
+ autoCorrelate(raw, 'opened')
298
+ _onTap?.(raw.userInfo ?? raw)
299
+ }
300
+ }
301
+
302
+ /** v2.26 — process one drained notification or tap for downstream
303
+ * correlation. No-op if the payload didn't carry `_sentori.msgId`
304
+ * (e.g. older server, or push from a non-Sentori sender). */
305
+ function autoCorrelate(
306
+ raw: Record<string, unknown>,
307
+ eventType: 'received' | 'opened',
308
+ ): void {
309
+ const userInfo = (raw.userInfo as Record<string, unknown> | undefined) ?? raw
310
+ const sentori = (userInfo._sentori as Record<string, unknown> | undefined) ?? undefined
311
+ const msgId = typeof sentori?.msgId === 'string' ? sentori.msgId : undefined
312
+ if (!msgId) return
313
+
314
+ const provider = guessProvider(raw)
315
+ const title = typeof raw.title === 'string' ? raw.title : undefined
316
+ const body = typeof raw.body === 'string' ? raw.body : undefined
317
+
318
+ // Breadcrumb buffer: O(1) in-memory push. Tag both event types
319
+ // ('received' vs 'opened') so a later captureException shows
320
+ // whether the user actually saw the push.
321
+ addBreadcrumb('push', { body, msgId, opened: eventType === 'opened', provider, title })
322
+
323
+ // Track event: reuses the existing SDK event pipeline. Two
324
+ // distinct names so dashboards can separate delivery from open.
325
+ const trackName = eventType === 'opened' ? 'sentori.push.opened' : 'sentori.push.received'
326
+ track(trackName, { msgId, provider })
327
+
328
+ // Enqueue ack — batched, see drainAckQueue.
329
+ enqueueAck(msgId)
330
+ }
331
+
332
+ function guessProvider(raw: Record<string, unknown>): string {
333
+ if (typeof raw.provider === 'string') return raw.provider
334
+ // iOS native delegate sets `category`; FCM service sets a top-level
335
+ // `from`. Use either as a heuristic; default 'unknown' rather than
336
+ // crashing the pipeline.
337
+ if (raw.from) return 'fcm'
338
+ if (raw.category) return 'apns'
339
+ return 'unknown'
340
+ }
341
+
342
+ function enqueueAck(msgId: string): void {
343
+ if (_ackQueue.includes(msgId)) return
344
+ _ackQueue.push(msgId)
345
+ if (!_ackFlushInterval) {
346
+ _ackFlushInterval = setInterval(() => {
347
+ void drainAckQueue()
348
+ }, ACK_FLUSH_INTERVAL_MS)
349
+ }
350
+ }
351
+
352
+ async function drainAckQueue(): Promise<void> {
353
+ if (_ackQueue.length === 0) return
354
+ const cfg = tryGetRuntimeConfig()
355
+ if (!cfg) return
356
+ const batch = _ackQueue.splice(0, _ackQueue.length)
357
+ // Fire-and-forget — server records first-ack only; subsequent
358
+ // requests are idempotent. Network failure means we lose that
359
+ // ack, which downgrades correlation precision but never breaks
360
+ // the user flow.
361
+ for (const msgId of batch) {
362
+ try {
363
+ await fetch(joinUrl(cfg.ingestUrl, `/v1/push/sends/${msgId}/ack`), {
364
+ body: JSON.stringify({ eventType: 'received', sessionId: _sessionId }),
365
+ headers: {
366
+ authorization: `Bearer ${cfg.token}`,
367
+ 'content-type': 'application/json',
368
+ },
369
+ method: 'POST',
370
+ })
371
+ } catch {
372
+ /* best-effort; ignore */
373
+ }
374
+ }
375
+ }
376
+
377
+ /** v2.26 — set the host's current session id so the next ack carries
378
+ * it. Useful for v2.27 correlation (push -> session -> events). */
379
+ export function setSessionContext(sessionId: null | string): void {
380
+ _sessionId = sessionId
381
+ }
382
+
383
+ function coerceNotification(raw: Record<string, unknown>): PushNotificationPayload {
384
+ return {
385
+ id: raw.id as string | undefined,
386
+ title: raw.title as string | undefined,
387
+ body: raw.body as string | undefined,
388
+ subtitle: raw.subtitle as string | undefined,
389
+ category: raw.category as string | undefined,
390
+ userInfo: raw.userInfo as Record<string, unknown> | undefined,
391
+ receivedAt: raw.receivedAt as number | undefined,
392
+ }
393
+ }
394
+
395
+ function startAppStateWatch(): void {
396
+ if (_appStateSubscription) return
397
+ try {
398
+ const rn = require('react-native') as { AppState?: AppStateModule }
399
+ const AppState = rn.AppState
400
+ if (!AppState) return
401
+ _backgrounded = AppState.currentState === 'background'
402
+ _appStateSubscription = AppState.addEventListener('change', (state: string) => {
403
+ _backgrounded = state === 'background'
404
+ })
405
+ } catch {
406
+ /* react-native unavailable (unit test) */
407
+ }
408
+ }
409
+
410
+ async function persistIpt(ipt: string): Promise<void> {
411
+ const storage = await tryAsyncStorage()
412
+ if (!storage) return
413
+ try {
414
+ await storage.setItem(STORAGE_KEY, ipt)
415
+ } catch (e) {
416
+ logger.warn('push', 'AsyncStorage.setItem failed', e)
417
+ }
418
+ }
419
+
420
+ async function clearPersistedIpt(): Promise<void> {
421
+ const storage = await tryAsyncStorage()
422
+ try {
423
+ await storage?.removeItem(STORAGE_KEY)
424
+ } catch (e) {
425
+ logger.warn('push', 'AsyncStorage.removeItem failed', e)
426
+ }
427
+ }
428
+
429
+ async function readPersistedIpt(): Promise<null | string> {
430
+ const storage = await tryAsyncStorage()
431
+ if (!storage) return null
432
+ try {
433
+ return await storage.getItem(STORAGE_KEY)
434
+ } catch {
435
+ return null
436
+ }
437
+ }
438
+
439
+ type AsyncStorageLike = {
440
+ getItem: (k: string) => Promise<null | string>
441
+ setItem: (k: string, v: string) => Promise<void>
442
+ removeItem: (k: string) => Promise<void>
443
+ }
444
+
445
+ async function tryAsyncStorage(): Promise<AsyncStorageLike | null> {
446
+ try {
447
+ const mod = require('@react-native-async-storage/async-storage') as {
448
+ default?: AsyncStorageLike
449
+ }
450
+ return mod.default ?? null
451
+ } catch {
452
+ return null
453
+ }
454
+ }
455
+
456
+ function joinUrl(base: string, path: string): string {
457
+ return `${base.replace(/\/+$/, '')}${path}`
458
+ }
459
+
460
+ let _platformOverride: 'ios' | 'android' | 'unknown' | null = null
461
+
462
+ /** Test-only hook to override Platform.OS detection. Production
463
+ * code paths must not call this. */
464
+ export function __setPlatformForTests(p: 'ios' | 'android' | 'unknown' | null): void {
465
+ _platformOverride = p
466
+ }
467
+
468
+ function detectPlatform(): 'ios' | 'android' | 'unknown' {
469
+ if (_platformOverride != null) return _platformOverride
470
+ try {
471
+ const rn = require('react-native') as { Platform?: { OS?: string } }
472
+ const os = rn.Platform?.OS
473
+ if (os === 'ios' || os === 'android') return os
474
+ } catch {
475
+ /* react-native unavailable */
476
+ }
477
+ return 'unknown'
478
+ }
479
+
480
+ declare const __DEV__: boolean | undefined