@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.
Files changed (50) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/AndroidManifest.xml +24 -1
  3. package/android/src/main/java/com/sentori/SentoriFirebaseMessagingService.kt +56 -0
  4. package/android/src/main/java/com/sentori/SentoriModule.kt +43 -0
  5. package/android/src/main/java/com/sentori/SentoriPushNotifications.kt +293 -0
  6. package/ios/SentoriModule.swift +39 -0
  7. package/ios/SentoriPushNotifications.swift +303 -0
  8. package/lib/capture.d.ts +6 -1
  9. package/lib/capture.d.ts.map +1 -1
  10. package/lib/capture.js +49 -2
  11. package/lib/capture.js.map +1 -1
  12. package/lib/compat/sentry.d.ts +14 -0
  13. package/lib/compat/sentry.d.ts.map +1 -1
  14. package/lib/compat/sentry.js +6 -0
  15. package/lib/compat/sentry.js.map +1 -1
  16. package/lib/config.d.ts +12 -18
  17. package/lib/config.d.ts.map +1 -1
  18. package/lib/config.js.map +1 -1
  19. package/lib/expo-compat.d.ts +270 -0
  20. package/lib/expo-compat.d.ts.map +1 -0
  21. package/lib/expo-compat.js +500 -0
  22. package/lib/expo-compat.js.map +1 -0
  23. package/lib/index.d.ts +17 -0
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +18 -0
  26. package/lib/index.js.map +1 -1
  27. package/lib/init.d.ts +7 -0
  28. package/lib/init.d.ts.map +1 -1
  29. package/lib/init.js +3 -0
  30. package/lib/init.js.map +1 -1
  31. package/lib/native.d.ts +17 -0
  32. package/lib/native.d.ts.map +1 -1
  33. package/lib/native.js +57 -0
  34. package/lib/native.js.map +1 -1
  35. package/lib/push.d.ts +58 -0
  36. package/lib/push.d.ts.map +1 -0
  37. package/lib/push.js +294 -0
  38. package/lib/push.js.map +1 -0
  39. package/package.json +9 -5
  40. package/src/__tests__/before-send.test.ts +72 -0
  41. package/src/__tests__/compat-sentry.test.ts +121 -0
  42. package/src/__tests__/push.test.ts +178 -0
  43. package/src/capture.ts +48 -2
  44. package/src/compat/sentry.ts +8 -0
  45. package/src/config.ts +12 -15
  46. package/src/expo-compat.ts +698 -0
  47. package/src/index.ts +40 -0
  48. package/src/init.ts +10 -0
  49. package/src/native.ts +102 -0
  50. 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