@txlemetry/react-native-plugin 2.5.1 → 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.
@@ -1,638 +1,638 @@
1
- package com.posthogreactnativeplugin
2
-
3
- import android.content.Intent
4
- import android.util.Log
5
- import com.facebook.react.bridge.Arguments
6
- import com.facebook.react.bridge.Promise
7
- import com.facebook.react.bridge.ReactApplicationContext
8
- import com.facebook.react.bridge.ReactContextBaseJavaModule
9
- import com.facebook.react.bridge.ReactMethod
10
- import com.facebook.react.bridge.ReadableMap
11
- import com.facebook.react.bridge.UiThreadUtil
12
- import com.facebook.react.common.JavascriptException
13
- import com.facebook.react.modules.core.DeviceEventManagerModule
14
- import com.posthog.PostHog
15
- import com.posthog.PostHogConfig
16
- import com.posthog.android.PostHogAndroid
17
- import com.posthog.android.PostHogAndroidConfig
18
- import com.posthog.internal.PostHogPreferences
19
- import com.posthog.internal.PostHogPreferences.Companion.ANONYMOUS_ID
20
- import com.posthog.internal.PostHogPreferences.Companion.DISTINCT_ID
21
- import com.posthog.internal.PostHogSessionManager
22
- import java.util.UUID
23
-
24
- class PosthogReactNativePluginModule(
25
- reactContext: ReactApplicationContext,
26
- ) : ReactContextBaseJavaModule(reactContext) {
27
- override fun getName(): String = NAME
28
-
29
- @ReactMethod
30
- fun setup(
31
- sessionId: String,
32
- sdkOptions: ReadableMap,
33
- pluginConfig: ReadableMap,
34
- promise: Promise,
35
- ) {
36
- val sessionReplayConfig = getMap(pluginConfig, "sessionReplay")
37
- val errorTrackingConfig = getMap(pluginConfig, "errorTracking")
38
-
39
- setupNativeSdk(
40
- method = "setup",
41
- sessionId = sessionId,
42
- sdkOptions = sdkOptions,
43
- sessionReplayEnabled = getBoolean(sessionReplayConfig, "enabled", false),
44
- sdkReplayConfig = getMap(sessionReplayConfig, "sdkReplayConfig"),
45
- decideReplayConfig = getMap(sessionReplayConfig, "decideReplayConfig"),
46
- nativeErrorTrackingAutocapture = getBoolean(errorTrackingConfig, "nativeAutocapture", false),
47
- exceptionStepsConfig = getMap(errorTrackingConfig, "exceptionSteps"),
48
- pushConfig = getMap(pluginConfig, "push"),
49
- promise = promise,
50
- )
51
- }
52
-
53
- @ReactMethod
54
- fun start(
55
- sessionId: String,
56
- sdkOptions: ReadableMap,
57
- sdkReplayConfig: ReadableMap,
58
- decideReplayConfig: ReadableMap,
59
- promise: Promise,
60
- ) {
61
- setupNativeSdk(
62
- method = "start",
63
- sessionId = sessionId,
64
- sdkOptions = sdkOptions,
65
- sessionReplayEnabled = true,
66
- sdkReplayConfig = sdkReplayConfig,
67
- decideReplayConfig = decideReplayConfig,
68
- nativeErrorTrackingAutocapture = false,
69
- exceptionStepsConfig = null,
70
- pushConfig = null,
71
- promise = promise,
72
- )
73
- }
74
-
75
- private fun setupNativeSdk(
76
- method: String,
77
- sessionId: String,
78
- sdkOptions: ReadableMap,
79
- sessionReplayEnabled: Boolean,
80
- sdkReplayConfig: ReadableMap?,
81
- decideReplayConfig: ReadableMap?,
82
- nativeErrorTrackingAutocapture: Boolean,
83
- exceptionStepsConfig: ReadableMap?,
84
- pushConfig: ReadableMap?,
85
- promise: Promise,
86
- ) {
87
- val initRunnable =
88
- Runnable {
89
- try {
90
- val uuid = UUID.fromString(sessionId)
91
- PostHogSessionManager.setSessionId(uuid)
92
-
93
- val context = this.reactApplicationContext
94
- val apiKey = getString(sdkOptions, "apiKey", "")
95
- val host = getString(sdkOptions, "host", PostHogConfig.DEFAULT_HOST)
96
- val debugValue = getBoolean(sdkOptions, "debug", false)
97
- val distinctId = getString(sdkOptions, "distinctId", "")
98
- val anonymousId = getString(sdkOptions, "anonymousId", "")
99
- val theSdkVersion = getString(sdkOptions, "sdkVersion", "")
100
- val theFlushAt = getInt(sdkOptions, "flushAt", DEFAULT_FLUSH_AT)
101
- val theOptOut = getBoolean(sdkOptions, "optOut", false)
102
- // Default true: an older JS layer that never sends this keeps native's own fetch.
103
- val thePreloadFeatureFlags = getBoolean(sdkOptions, "preloadFeatureFlags", true)
104
-
105
- // Forward custom headers (e.g. Authorization for a reverse proxy) so the native SDK
106
- // attaches them to the requests it sends directly (session replay, crash uploads).
107
- val theRequestHeaders =
108
- getMap(sdkOptions, "requestHeaders")
109
- ?.toHashMap()
110
- ?.filterValues { it is String }
111
- ?.mapValues { it.value as String }
112
-
113
- val config =
114
- PostHogAndroidConfig(apiKey, host).apply {
115
- debug = debugValue
116
- optOut = theOptOut
117
- preloadFeatureFlags = thePreloadFeatureFlags
118
- captureDeepLinks = false
119
- captureApplicationLifecycleEvents = false
120
- captureScreenViews = false
121
- flushAt = theFlushAt
122
- theRequestHeaders?.let { requestHeaders = it }
123
- errorTrackingConfig.autoCapture = nativeErrorTrackingAutocapture
124
-
125
- // Keep the native exception-steps buffer aligned with the JS layer (one logical buffer).
126
- // Absent keys fall back to the native defaults the helpers receive.
127
- errorTrackingConfig.exceptionSteps.enabled =
128
- getBoolean(exceptionStepsConfig, "enabled", errorTrackingConfig.exceptionSteps.enabled)
129
- errorTrackingConfig.exceptionSteps.maxBytes =
130
- getInt(exceptionStepsConfig, "maxBytes", errorTrackingConfig.exceptionSteps.maxBytes)
131
-
132
- // React Native rethrows fatal JS errors natively as JavascriptException.
133
- // The JS layer already captured them, so drop the native duplicate.
134
- errorTrackingConfig.ignoredExceptionTypes.add(JavascriptException::class.java)
135
-
136
- // Always apply the session replay configuration so that recording started later
137
- // (e.g. startRecording or a linked feature flag) uses the right mode and masking;
138
- // sessionReplayEnabled only controls whether recording starts at setup.
139
- val maskAllTextInputs = getBoolean(sdkReplayConfig, "maskAllTextInputs", DEFAULT_MASK_ALL_TEXT_INPUTS)
140
- val maskAllImages = getBoolean(sdkReplayConfig, "maskAllImages", DEFAULT_MASK_ALL_IMAGES)
141
- val captureLog = getBoolean(sdkReplayConfig, "captureLog", DEFAULT_CAPTURE_LOG)
142
-
143
- // read throttleDelayMs and use androidDebouncerDelayMs as a fallback for back compatibility
144
- val throttleDelayMs =
145
- when {
146
- hasKey(sdkReplayConfig, "throttleDelayMs") -> getInt(sdkReplayConfig, "throttleDelayMs", DEFAULT_THROTTLE_DELAY_MS)
147
- hasKey(
148
- sdkReplayConfig,
149
- "androidDebouncerDelayMs",
150
- ) -> getInt(sdkReplayConfig, "androidDebouncerDelayMs", DEFAULT_THROTTLE_DELAY_MS)
151
- else -> DEFAULT_THROTTLE_DELAY_MS
152
- }
153
-
154
- sessionReplay = sessionReplayEnabled
155
- sessionReplayConfig.screenshot = true
156
- sessionReplayConfig.captureLogcat = captureLog
157
- sessionReplayConfig.throttleDelayMs = throttleDelayMs.toLong()
158
- sessionReplayConfig.maskAllImages = maskAllImages
159
- sessionReplayConfig.maskAllTextInputs = maskAllTextInputs
160
- sessionReplayConfig.sampleRate = getDoubleOrNull(sdkReplayConfig, "sampleRate")
161
- sessionReplayConfig.verifyScreenshotMaskAlignment =
162
- getBoolean(sdkReplayConfig, "verifyScreenshotMaskAlignment", false)
163
-
164
- val endpoint = getString(decideReplayConfig, "endpoint", "")
165
- if (endpoint.isNotEmpty()) {
166
- snapshotEndpoint = endpoint
167
- }
168
-
169
- // Only set when present: the legacy start() path predates push, and there the
170
- // native defaults (both true) must win, matching posthog-android on its own.
171
- if (hasKey(pushConfig, "capturePushNotificationSubscriptions")) {
172
- capturePushNotificationSubscriptions =
173
- getBoolean(pushConfig, "capturePushNotificationSubscriptions", true)
174
- }
175
- if (hasKey(pushConfig, "capturePushNotificationOpened")) {
176
- capturePushNotificationOpened = getBoolean(pushConfig, "capturePushNotificationOpened", true)
177
- }
178
-
179
- // Installed only when JS asked for it: an uninvited bridging provider would
180
- // change how the native SDK handles a 401 on the subscription call.
181
- if (getBoolean(pushConfig, "pushIdentityProviderEnabled", false)) {
182
- pushModule = this@PosthogReactNativePluginModule
183
- pushIdentityProvider = { distinctId, appId, completion ->
184
- requestPushIdentityToken(distinctId, appId, completion)
185
- }
186
- }
187
-
188
- if (theSdkVersion.isNotEmpty()) {
189
- sdkName = "posthog-react-native"
190
- sdkVersion = theSdkVersion
191
- }
192
- }
193
- PostHogAndroid.setup(context, config)
194
-
195
- setIdentify(config.cachePreferences, distinctId, anonymousId)
196
-
197
- captureColdStartPushOpenIfNeeded(config)
198
- } catch (e: Throwable) {
199
- logError(method, e)
200
- } finally {
201
- promise.resolve(null)
202
- }
203
- }
204
-
205
- // forces the SDK to be initialized on the main thread
206
- if (UiThreadUtil.isOnUiThread()) {
207
- initRunnable.run()
208
- } else {
209
- UiThreadUtil.runOnUiThread(initRunnable)
210
- }
211
- }
212
-
213
- @ReactMethod
214
- fun startSession(
215
- sessionId: String,
216
- promise: Promise,
217
- ) {
218
- try {
219
- val uuid = UUID.fromString(sessionId)
220
- PostHogSessionManager.setSessionId(uuid)
221
- PostHog.startSession()
222
- } catch (e: Throwable) {
223
- logError("startSession", e)
224
- } finally {
225
- promise.resolve(null)
226
- }
227
- }
228
-
229
- @ReactMethod
230
- fun isEnabled(promise: Promise) {
231
- try {
232
- promise.resolve(PostHog.isSessionReplayActive())
233
- } catch (e: Throwable) {
234
- logError("isEnabled", e)
235
- promise.resolve(false)
236
- }
237
- }
238
-
239
- @ReactMethod
240
- fun endSession(promise: Promise) {
241
- try {
242
- PostHog.endSession()
243
- } catch (e: Throwable) {
244
- logError("endSession", e)
245
- } finally {
246
- promise.resolve(null)
247
- }
248
- }
249
-
250
- @ReactMethod
251
- fun identify(
252
- distinctId: String,
253
- anonymousId: String,
254
- promise: Promise,
255
- ) {
256
- try {
257
- setIdentify(PostHog.getConfig<PostHogConfig>()?.cachePreferences, distinctId, anonymousId)
258
- } catch (e: Throwable) {
259
- logError("identify", e)
260
- } finally {
261
- promise.resolve(null)
262
- }
263
- }
264
-
265
- // Calls the native SDK rather than writing preferences like identify() does: reset() is what
266
- // unregisters the logged-out user's push subscription and re-registers under the new identity.
267
- @ReactMethod
268
- fun reset(
269
- distinctId: String,
270
- anonymousId: String,
271
- promise: Promise,
272
- ) {
273
- try {
274
- PostHog.reset()
275
- // Native reset() mints its own anonymous id; overwrite it with the JS one so the two
276
- // SDKs stay on the same identity. Must run after reset(), which needs the pre-reset
277
- // distinctId to know which subscription to unregister. Known gap (as on iOS): native's
278
- // async push re-registration can read the identity before this write lands and register
279
- // under a throwaway id; retryPending() converges it on the next flush.
280
- setIdentify(PostHog.getConfig<PostHogConfig>()?.cachePreferences, distinctId, anonymousId)
281
- } catch (e: Throwable) {
282
- logError("reset", e)
283
- } finally {
284
- promise.resolve(null)
285
- }
286
- }
287
-
288
- // Runtime consent changes must reach native: it persists its own opt-out flag and only reads
289
- // the JS value at setup(), so a refreshed FCM token could otherwise auto-register after the
290
- // user opted out. optIn() also resumes deferred push work on the next flush.
291
- @ReactMethod
292
- fun setOptOut(
293
- optOut: Boolean,
294
- promise: Promise,
295
- ) {
296
- try {
297
- if (optOut) {
298
- PostHog.optOut()
299
- } else {
300
- PostHog.optIn()
301
- }
302
- } catch (e: Throwable) {
303
- logError("setOptOut", e)
304
- } finally {
305
- promise.resolve(null)
306
- }
307
- }
308
-
309
- private fun setIdentify(
310
- cachePreferences: PostHogPreferences?,
311
- distinctId: String,
312
- anonymousId: String,
313
- ) {
314
- cachePreferences?.let { preferences ->
315
- if (anonymousId.isNotEmpty()) {
316
- preferences.setValue(ANONYMOUS_ID, anonymousId)
317
- }
318
- if (distinctId.isNotEmpty()) {
319
- preferences.setValue(DISTINCT_ID, distinctId)
320
- }
321
- }
322
- }
323
-
324
- @ReactMethod
325
- fun startRecording(
326
- resumeCurrent: Boolean,
327
- promise: Promise,
328
- ) {
329
- try {
330
- PostHog.startSessionReplay(resumeCurrent)
331
- } catch (e: Throwable) {
332
- logError("startRecording", e)
333
- } finally {
334
- promise.resolve(null)
335
- }
336
- }
337
-
338
- @ReactMethod
339
- fun stopRecording(promise: Promise) {
340
- try {
341
- PostHog.stopSessionReplay()
342
- } catch (e: Throwable) {
343
- logError("stopRecording", e)
344
- } finally {
345
- promise.resolve(null)
346
- }
347
- }
348
-
349
- @ReactMethod
350
- fun addExceptionStep(
351
- message: String,
352
- properties: ReadableMap?,
353
- promise: Promise,
354
- ) {
355
- try {
356
- // ReadableMap.toHashMap() is HashMap<String, Any?>; the native API takes Map<String, Any>?.
357
- @Suppress("UNCHECKED_CAST")
358
- val nativeProperties = properties?.toHashMap() as Map<String, Any>?
359
- PostHog.addExceptionStep(message, nativeProperties)
360
- } catch (e: Throwable) {
361
- logError("addExceptionStep", e)
362
- } finally {
363
- promise.resolve(null)
364
- }
365
- }
366
-
367
- private fun getMap(
368
- map: ReadableMap?,
369
- key: String,
370
- ): ReadableMap? =
371
- runCatching {
372
- if (map != null && map.hasKey(key) && !map.isNull(key)) {
373
- map.getMap(key)
374
- } else {
375
- null
376
- }
377
- }.getOrNull()
378
-
379
- private fun getString(
380
- map: ReadableMap?,
381
- key: String,
382
- default: String,
383
- ): String = runCatching { if (hasKey(map, key)) map?.getString(key) ?: default else default }.getOrDefault(default)
384
-
385
- private fun getInt(
386
- map: ReadableMap?,
387
- key: String,
388
- default: Int,
389
- ): Int = runCatching { if (hasKey(map, key)) map?.getInt(key) ?: default else default }.getOrDefault(default)
390
-
391
- private fun getDoubleOrNull(
392
- map: ReadableMap?,
393
- key: String,
394
- ): Double? = runCatching { if (hasKey(map, key)) map?.getDouble(key) else null }.getOrNull()
395
-
396
- private fun logError(
397
- method: String,
398
- error: Throwable,
399
- ) {
400
- Log.println(Log.ERROR, POSTHOG_TAG, "Method $method, error: $error")
401
- }
402
-
403
- // These reject instead of this module's usual swallow-and-resolve convention: a failed
404
- // registration is a distinct signal, not a silent success. The JS layer's public methods
405
- // never throw, so the rejection is what gives it something to log.
406
- @ReactMethod
407
- fun registerPushNotificationToken(
408
- deviceToken: String?,
409
- appId: String?,
410
- promise: Promise,
411
- ) {
412
- try {
413
- if (deviceToken.isNullOrBlank()) {
414
- // A blank token is dropped silently by the native SDK, so surface it here
415
- // instead of reporting false success.
416
- promise.reject(
417
- PUSH_ERROR_CODE,
418
- "registerPushNotificationToken: deviceToken is blank; token not registered.",
419
- )
420
- return
421
- }
422
- val resolvedAppId = appId?.trim()?.takeIf { it.isNotEmpty() } ?: firebaseProjectId
423
- if (resolvedAppId.isNullOrEmpty()) {
424
- val message =
425
- "registerPushNotificationToken: no appId provided and no Firebase project id " +
426
- "could be resolved, skipping. Pass appId explicitly if this app does not use Firebase."
427
- Log.w(POSTHOG_TAG, message)
428
- promise.reject(PUSH_ERROR_CODE, message)
429
- return
430
- }
431
- PostHog.registerPushNotificationToken(deviceToken, resolvedAppId)
432
- promise.resolve(null)
433
- } catch (e: Throwable) {
434
- logError("registerPushNotificationToken", e)
435
- promise.reject(PUSH_ERROR_CODE, e)
436
- }
437
- }
438
-
439
- @ReactMethod
440
- fun unregisterPushNotificationToken(promise: Promise) {
441
- try {
442
- PostHog.unregisterPushNotificationToken()
443
- promise.resolve(null)
444
- } catch (e: Throwable) {
445
- logError("unregisterPushNotificationToken", e)
446
- promise.reject(PUSH_ERROR_CODE, e)
447
- }
448
- }
449
-
450
- @ReactMethod
451
- fun capturePushNotificationOpened(
452
- properties: ReadableMap,
453
- promise: Promise,
454
- ) {
455
- try {
456
- // subtitle is iOS-only; posthog-android's capturePushNotificationOpened has no
457
- // subtitle parameter, so JS's value is dropped here.
458
- val title = if (hasKey(properties, "title")) properties.getString("title") else null
459
- val body = if (hasKey(properties, "body")) properties.getString("body") else null
460
- val payload = if (hasKey(properties, "payload")) properties.getMap("payload")?.toHashMap() else null
461
- val action = if (hasKey(properties, "action")) properties.getString("action") else null
462
- PostHog.capturePushNotificationOpened(title, body, payload, action)
463
- promise.resolve(null)
464
- } catch (e: Throwable) {
465
- logError("capturePushNotificationOpened", e)
466
- promise.reject(PUSH_ERROR_CODE, e)
467
- }
468
- }
469
-
470
- // posthog-android's open-capture integration registers ActivityLifecycleCallbacks during
471
- // setup(), which the bridge reaches only after the launch Activity was created — so the
472
- // cold-start tray tap it exists for is the one creation it can never observe here. Read
473
- // the launch intent directly, then strip the marker so the integration (or a re-run)
474
- // can't capture the same tap again from this intent object.
475
- private fun captureColdStartPushOpenIfNeeded(config: PostHogAndroidConfig) {
476
- if (!config.capturePushNotificationOpened) {
477
- return
478
- }
479
- val intent = reactApplicationContext.currentActivity?.intent ?: return
480
- // A relaunch from recents redelivers the original tray intent to a fresh activity;
481
- // capturing it would count a days-old tap as a new open.
482
- if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) {
483
- return
484
- }
485
- try {
486
- intent.getStringExtra(GOOGLE_MESSAGE_ID) ?: return
487
- // Unmarshalling extras throws BadParcelableException on a Parcelable class this
488
- // classloader lacks; read before stripping the marker so a failed read leaves the
489
- // intent as the native integration expects it.
490
- val payload =
491
- intent.extras?.let { bundle ->
492
- bundle.keySet().associateWith { key ->
493
- @Suppress("DEPRECATION")
494
- bundle.get(key)
495
- }
496
- }
497
- intent.removeExtra(GOOGLE_MESSAGE_ID)
498
- PostHog.capturePushNotificationOpened(null, null, payload, null)
499
- } catch (e: Throwable) {
500
- logError("capturePushNotificationOpened", e)
501
- }
502
- }
503
-
504
- @ReactMethod
505
- fun providePushIdentityToken(
506
- requestId: String?,
507
- token: String?,
508
- promise: Promise,
509
- ) {
510
- try {
511
- if (requestId != null) {
512
- pushIdentityCompletions.remove(requestId)?.invoke(token)
513
- }
514
- } catch (e: Throwable) {
515
- logError("providePushIdentityToken", e)
516
- } finally {
517
- promise.resolve(null)
518
- }
519
- }
520
-
521
- // Required by NativeEventEmitter on the old architecture; events are broadcast via
522
- // RCTDeviceEventEmitter, so there is nothing to do here.
523
- @ReactMethod
524
- fun addListener(eventName: String?) = Unit
525
-
526
- @ReactMethod
527
- fun removeListeners(count: Int) = Unit
528
-
529
- override fun invalidate() {
530
- if (pushModule === this) {
531
- // Decline mints fast after teardown instead of stalling the native 10s watchdog.
532
- pushModule = null
533
- }
534
- super.invalidate()
535
- }
536
-
537
- companion object {
538
- const val NAME = "PosthogReactNativePlugin"
539
- const val POSTHOG_TAG = "PostHog"
540
-
541
- // FCM stamps this extra on the tray-tap launch intent; mirrors posthog-android's
542
- // PostHogActivityLifecycleCallbackIntegration.
543
- private const val GOOGLE_MESSAGE_ID = "google.message_id"
544
-
545
- // Default session replay configuration values
546
- const val DEFAULT_MASK_ALL_TEXT_INPUTS = true
547
- const val DEFAULT_MASK_ALL_IMAGES = true
548
- const val DEFAULT_CAPTURE_LOG = true
549
- const val DEFAULT_FLUSH_AT = 20
550
- const val DEFAULT_THROTTLE_DELAY_MS = 1000
551
-
552
- private const val PUSH_ERROR_CODE = "PosthogReactNativePluginError"
553
- private const val PUSH_IDENTITY_EVENT = "PostHogPushIdentityRequest"
554
- private const val PUSH_IDENTITY_REPLY_TTL_MS = 15_000L
555
-
556
- // Modules die on every bridge reload, so the provider closure resolves the live
557
- // module through this static reference at call time, not a captured setup-time one.
558
- @Volatile
559
- private var pushModule: PosthogReactNativePluginModule? = null
560
-
561
- private val pushIdentityCompletions = java.util.concurrent.ConcurrentHashMap<String, (String?) -> Unit>()
562
-
563
- private fun requestPushIdentityToken(
564
- distinctId: String,
565
- appId: String,
566
- completion: (String?) -> Unit,
567
- ) {
568
- val module = pushModule
569
- val context = module?.reactApplicationContext
570
- if (module == null || context == null || !context.hasActiveReactInstance()) {
571
- declinePushIdentity(completion, "no React instance attached")
572
- return
573
- }
574
- val requestId = UUID.randomUUID().toString()
575
- pushIdentityCompletions[requestId] = completion
576
- try {
577
- val params =
578
- Arguments.createMap().apply {
579
- putString("requestId", requestId)
580
- putString("distinctId", distinctId)
581
- putString("appId", appId)
582
- }
583
- context
584
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
585
- .emit(PUSH_IDENTITY_EVENT, params)
586
- } catch (e: Throwable) {
587
- pushIdentityCompletions.remove(requestId)
588
- declinePushIdentity(completion, "failed to reach JS: ${e.message}")
589
- return
590
- }
591
- // The native SDK's own 10s mint watchdog handles the fallback; this only drops the
592
- // entry so a late JS reply is ignored and the completion doesn't leak.
593
- UiThreadUtil.runOnUiThread({ pushIdentityCompletions.remove(requestId) }, PUSH_IDENTITY_REPLY_TTL_MS)
594
- }
595
-
596
- // A null identity token sends the request unauthenticated, which a project requiring
597
- // identity verification rejects server-side. Log the reason so that failure is
598
- // greppable and distinct from a host that deliberately returned null.
599
- private fun declinePushIdentity(
600
- completion: (String?) -> Unit,
601
- reason: String,
602
- ) {
603
- Log.w(POSTHOG_TAG, "Push subscription will be sent unauthenticated: $reason")
604
- completion(null)
605
- }
606
-
607
- @Volatile private var cachedFirebaseProjectId: String? = null
608
-
609
- // No Firebase dependency here, so the project id is looked up reflectively and any
610
- // failure (class missing, Firebase not initialized) is swallowed. Only a successful lookup
611
- // is cached: getInstance() throws until Firebase initializes, so memoizing the null would
612
- // strand every later token-refresh call with no resolvable appId for the process lifetime.
613
- private val firebaseProjectId: String?
614
- get() =
615
- cachedFirebaseProjectId
616
- ?: try {
617
- val firebaseAppClass = Class.forName("com.google.firebase.FirebaseApp")
618
- val firebaseApp = firebaseAppClass.getMethod("getInstance").invoke(null)
619
- val options = firebaseAppClass.getMethod("getOptions").invoke(firebaseApp)
620
- (options?.javaClass?.getMethod("getProjectId")?.invoke(options) as? String)?.also {
621
- cachedFirebaseProjectId = it
622
- }
623
- } catch (e: Throwable) {
624
- null
625
- }
626
- }
627
- }
628
-
629
- private fun hasKey(
630
- map: ReadableMap?,
631
- key: String,
632
- ): Boolean = runCatching { map != null && map.hasKey(key) && !map.isNull(key) }.getOrDefault(false)
633
-
634
- internal fun getBoolean(
635
- map: ReadableMap?,
636
- key: String,
637
- default: Boolean,
638
- ): Boolean = runCatching { if (hasKey(map, key)) map?.getBoolean(key) ?: default else default }.getOrDefault(default)
1
+ package com.txlemetryreactnativeplugin
2
+
3
+ import android.content.Intent
4
+ import android.util.Log
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
9
+ import com.facebook.react.bridge.ReactMethod
10
+ import com.facebook.react.bridge.ReadableMap
11
+ import com.facebook.react.bridge.UiThreadUtil
12
+ import com.facebook.react.common.JavascriptException
13
+ import com.facebook.react.modules.core.DeviceEventManagerModule
14
+ import com.txlemetry.Txlemetry
15
+ import com.txlemetry.TxlemetryConfig
16
+ import com.txlemetry.android.TxlemetryAndroid
17
+ import com.txlemetry.android.TxlemetryAndroidConfig
18
+ import com.txlemetry.internal.TxlemetryPreferences
19
+ import com.txlemetry.internal.TxlemetryPreferences.Companion.ANONYMOUS_ID
20
+ import com.txlemetry.internal.TxlemetryPreferences.Companion.DISTINCT_ID
21
+ import com.txlemetry.internal.TxlemetrySessionManager
22
+ import java.util.UUID
23
+
24
+ class TxlemetryReactNativePluginModule(
25
+ reactContext: ReactApplicationContext,
26
+ ) : ReactContextBaseJavaModule(reactContext) {
27
+ override fun getName(): String = NAME
28
+
29
+ @ReactMethod
30
+ fun setup(
31
+ sessionId: String,
32
+ sdkOptions: ReadableMap,
33
+ pluginConfig: ReadableMap,
34
+ promise: Promise,
35
+ ) {
36
+ val sessionReplayConfig = getMap(pluginConfig, "sessionReplay")
37
+ val errorTrackingConfig = getMap(pluginConfig, "errorTracking")
38
+
39
+ setupNativeSdk(
40
+ method = "setup",
41
+ sessionId = sessionId,
42
+ sdkOptions = sdkOptions,
43
+ sessionReplayEnabled = getBoolean(sessionReplayConfig, "enabled", false),
44
+ sdkReplayConfig = getMap(sessionReplayConfig, "sdkReplayConfig"),
45
+ decideReplayConfig = getMap(sessionReplayConfig, "decideReplayConfig"),
46
+ nativeErrorTrackingAutocapture = getBoolean(errorTrackingConfig, "nativeAutocapture", false),
47
+ exceptionStepsConfig = getMap(errorTrackingConfig, "exceptionSteps"),
48
+ pushConfig = getMap(pluginConfig, "push"),
49
+ promise = promise,
50
+ )
51
+ }
52
+
53
+ @ReactMethod
54
+ fun start(
55
+ sessionId: String,
56
+ sdkOptions: ReadableMap,
57
+ sdkReplayConfig: ReadableMap,
58
+ decideReplayConfig: ReadableMap,
59
+ promise: Promise,
60
+ ) {
61
+ setupNativeSdk(
62
+ method = "start",
63
+ sessionId = sessionId,
64
+ sdkOptions = sdkOptions,
65
+ sessionReplayEnabled = true,
66
+ sdkReplayConfig = sdkReplayConfig,
67
+ decideReplayConfig = decideReplayConfig,
68
+ nativeErrorTrackingAutocapture = false,
69
+ exceptionStepsConfig = null,
70
+ pushConfig = null,
71
+ promise = promise,
72
+ )
73
+ }
74
+
75
+ private fun setupNativeSdk(
76
+ method: String,
77
+ sessionId: String,
78
+ sdkOptions: ReadableMap,
79
+ sessionReplayEnabled: Boolean,
80
+ sdkReplayConfig: ReadableMap?,
81
+ decideReplayConfig: ReadableMap?,
82
+ nativeErrorTrackingAutocapture: Boolean,
83
+ exceptionStepsConfig: ReadableMap?,
84
+ pushConfig: ReadableMap?,
85
+ promise: Promise,
86
+ ) {
87
+ val initRunnable =
88
+ Runnable {
89
+ try {
90
+ val uuid = UUID.fromString(sessionId)
91
+ TxlemetrySessionManager.setSessionId(uuid)
92
+
93
+ val context = this.reactApplicationContext
94
+ val apiKey = getString(sdkOptions, "apiKey", "")
95
+ val host = getString(sdkOptions, "host", TxlemetryConfig.DEFAULT_HOST)
96
+ val debugValue = getBoolean(sdkOptions, "debug", false)
97
+ val distinctId = getString(sdkOptions, "distinctId", "")
98
+ val anonymousId = getString(sdkOptions, "anonymousId", "")
99
+ val theSdkVersion = getString(sdkOptions, "sdkVersion", "")
100
+ val theFlushAt = getInt(sdkOptions, "flushAt", DEFAULT_FLUSH_AT)
101
+ val theOptOut = getBoolean(sdkOptions, "optOut", false)
102
+ // Default true: an older JS layer that never sends this keeps native's own fetch.
103
+ val thePreloadFeatureFlags = getBoolean(sdkOptions, "preloadFeatureFlags", true)
104
+
105
+ // Forward custom headers (e.g. Authorization for a reverse proxy) so the native SDK
106
+ // attaches them to the requests it sends directly (session replay, crash uploads).
107
+ val theRequestHeaders =
108
+ getMap(sdkOptions, "requestHeaders")
109
+ ?.toHashMap()
110
+ ?.filterValues { it is String }
111
+ ?.mapValues { it.value as String }
112
+
113
+ val config =
114
+ TxlemetryAndroidConfig(apiKey, host).apply {
115
+ debug = debugValue
116
+ optOut = theOptOut
117
+ preloadFeatureFlags = thePreloadFeatureFlags
118
+ captureDeepLinks = false
119
+ captureApplicationLifecycleEvents = false
120
+ captureScreenViews = false
121
+ flushAt = theFlushAt
122
+ theRequestHeaders?.let { requestHeaders = it }
123
+ errorTrackingConfig.autoCapture = nativeErrorTrackingAutocapture
124
+
125
+ // Keep the native exception-steps buffer aligned with the JS layer (one logical buffer).
126
+ // Absent keys fall back to the native defaults the helpers receive.
127
+ errorTrackingConfig.exceptionSteps.enabled =
128
+ getBoolean(exceptionStepsConfig, "enabled", errorTrackingConfig.exceptionSteps.enabled)
129
+ errorTrackingConfig.exceptionSteps.maxBytes =
130
+ getInt(exceptionStepsConfig, "maxBytes", errorTrackingConfig.exceptionSteps.maxBytes)
131
+
132
+ // React Native rethrows fatal JS errors natively as JavascriptException.
133
+ // The JS layer already captured them, so drop the native duplicate.
134
+ errorTrackingConfig.ignoredExceptionTypes.add(JavascriptException::class.java)
135
+
136
+ // Always apply the session replay configuration so that recording started later
137
+ // (e.g. startRecording or a linked feature flag) uses the right mode and masking;
138
+ // sessionReplayEnabled only controls whether recording starts at setup.
139
+ val maskAllTextInputs = getBoolean(sdkReplayConfig, "maskAllTextInputs", DEFAULT_MASK_ALL_TEXT_INPUTS)
140
+ val maskAllImages = getBoolean(sdkReplayConfig, "maskAllImages", DEFAULT_MASK_ALL_IMAGES)
141
+ val captureLog = getBoolean(sdkReplayConfig, "captureLog", DEFAULT_CAPTURE_LOG)
142
+
143
+ // read throttleDelayMs and use androidDebouncerDelayMs as a fallback for back compatibility
144
+ val throttleDelayMs =
145
+ when {
146
+ hasKey(sdkReplayConfig, "throttleDelayMs") -> getInt(sdkReplayConfig, "throttleDelayMs", DEFAULT_THROTTLE_DELAY_MS)
147
+ hasKey(
148
+ sdkReplayConfig,
149
+ "androidDebouncerDelayMs",
150
+ ) -> getInt(sdkReplayConfig, "androidDebouncerDelayMs", DEFAULT_THROTTLE_DELAY_MS)
151
+ else -> DEFAULT_THROTTLE_DELAY_MS
152
+ }
153
+
154
+ sessionReplay = sessionReplayEnabled
155
+ sessionReplayConfig.screenshot = true
156
+ sessionReplayConfig.captureLogcat = captureLog
157
+ sessionReplayConfig.throttleDelayMs = throttleDelayMs.toLong()
158
+ sessionReplayConfig.maskAllImages = maskAllImages
159
+ sessionReplayConfig.maskAllTextInputs = maskAllTextInputs
160
+ sessionReplayConfig.sampleRate = getDoubleOrNull(sdkReplayConfig, "sampleRate")
161
+ sessionReplayConfig.verifyScreenshotMaskAlignment =
162
+ getBoolean(sdkReplayConfig, "verifyScreenshotMaskAlignment", false)
163
+
164
+ val endpoint = getString(decideReplayConfig, "endpoint", "")
165
+ if (endpoint.isNotEmpty()) {
166
+ snapshotEndpoint = endpoint
167
+ }
168
+
169
+ // Only set when present: the legacy start() path predates push, and there the
170
+ // native defaults (both true) must win, matching txlemetry-android on its own.
171
+ if (hasKey(pushConfig, "capturePushNotificationSubscriptions")) {
172
+ capturePushNotificationSubscriptions =
173
+ getBoolean(pushConfig, "capturePushNotificationSubscriptions", true)
174
+ }
175
+ if (hasKey(pushConfig, "capturePushNotificationOpened")) {
176
+ capturePushNotificationOpened = getBoolean(pushConfig, "capturePushNotificationOpened", true)
177
+ }
178
+
179
+ // Installed only when JS asked for it: an uninvited bridging provider would
180
+ // change how the native SDK handles a 401 on the subscription call.
181
+ if (getBoolean(pushConfig, "pushIdentityProviderEnabled", false)) {
182
+ pushModule = this@TxlemetryReactNativePluginModule
183
+ pushIdentityProvider = { distinctId, appId, completion ->
184
+ requestPushIdentityToken(distinctId, appId, completion)
185
+ }
186
+ }
187
+
188
+ if (theSdkVersion.isNotEmpty()) {
189
+ sdkName = "posthog-react-native"
190
+ sdkVersion = theSdkVersion
191
+ }
192
+ }
193
+ TxlemetryAndroid.setup(context, config)
194
+
195
+ setIdentify(config.cachePreferences, distinctId, anonymousId)
196
+
197
+ captureColdStartPushOpenIfNeeded(config)
198
+ } catch (e: Throwable) {
199
+ logError(method, e)
200
+ } finally {
201
+ promise.resolve(null)
202
+ }
203
+ }
204
+
205
+ // forces the SDK to be initialized on the main thread
206
+ if (UiThreadUtil.isOnUiThread()) {
207
+ initRunnable.run()
208
+ } else {
209
+ UiThreadUtil.runOnUiThread(initRunnable)
210
+ }
211
+ }
212
+
213
+ @ReactMethod
214
+ fun startSession(
215
+ sessionId: String,
216
+ promise: Promise,
217
+ ) {
218
+ try {
219
+ val uuid = UUID.fromString(sessionId)
220
+ TxlemetrySessionManager.setSessionId(uuid)
221
+ Txlemetry.startSession()
222
+ } catch (e: Throwable) {
223
+ logError("startSession", e)
224
+ } finally {
225
+ promise.resolve(null)
226
+ }
227
+ }
228
+
229
+ @ReactMethod
230
+ fun isEnabled(promise: Promise) {
231
+ try {
232
+ promise.resolve(Txlemetry.isSessionReplayActive())
233
+ } catch (e: Throwable) {
234
+ logError("isEnabled", e)
235
+ promise.resolve(false)
236
+ }
237
+ }
238
+
239
+ @ReactMethod
240
+ fun endSession(promise: Promise) {
241
+ try {
242
+ Txlemetry.endSession()
243
+ } catch (e: Throwable) {
244
+ logError("endSession", e)
245
+ } finally {
246
+ promise.resolve(null)
247
+ }
248
+ }
249
+
250
+ @ReactMethod
251
+ fun identify(
252
+ distinctId: String,
253
+ anonymousId: String,
254
+ promise: Promise,
255
+ ) {
256
+ try {
257
+ setIdentify(Txlemetry.getConfig<TxlemetryConfig>()?.cachePreferences, distinctId, anonymousId)
258
+ } catch (e: Throwable) {
259
+ logError("identify", e)
260
+ } finally {
261
+ promise.resolve(null)
262
+ }
263
+ }
264
+
265
+ // Calls the native SDK rather than writing preferences like identify() does: reset() is what
266
+ // unregisters the logged-out user's push subscription and re-registers under the new identity.
267
+ @ReactMethod
268
+ fun reset(
269
+ distinctId: String,
270
+ anonymousId: String,
271
+ promise: Promise,
272
+ ) {
273
+ try {
274
+ Txlemetry.reset()
275
+ // Native reset() mints its own anonymous id; overwrite it with the JS one so the two
276
+ // SDKs stay on the same identity. Must run after reset(), which needs the pre-reset
277
+ // distinctId to know which subscription to unregister. Known gap (as on iOS): native's
278
+ // async push re-registration can read the identity before this write lands and register
279
+ // under a throwaway id; retryPending() converges it on the next flush.
280
+ setIdentify(Txlemetry.getConfig<TxlemetryConfig>()?.cachePreferences, distinctId, anonymousId)
281
+ } catch (e: Throwable) {
282
+ logError("reset", e)
283
+ } finally {
284
+ promise.resolve(null)
285
+ }
286
+ }
287
+
288
+ // Runtime consent changes must reach native: it persists its own opt-out flag and only reads
289
+ // the JS value at setup(), so a refreshed FCM token could otherwise auto-register after the
290
+ // user opted out. optIn() also resumes deferred push work on the next flush.
291
+ @ReactMethod
292
+ fun setOptOut(
293
+ optOut: Boolean,
294
+ promise: Promise,
295
+ ) {
296
+ try {
297
+ if (optOut) {
298
+ Txlemetry.optOut()
299
+ } else {
300
+ Txlemetry.optIn()
301
+ }
302
+ } catch (e: Throwable) {
303
+ logError("setOptOut", e)
304
+ } finally {
305
+ promise.resolve(null)
306
+ }
307
+ }
308
+
309
+ private fun setIdentify(
310
+ cachePreferences: TxlemetryPreferences?,
311
+ distinctId: String,
312
+ anonymousId: String,
313
+ ) {
314
+ cachePreferences?.let { preferences ->
315
+ if (anonymousId.isNotEmpty()) {
316
+ preferences.setValue(ANONYMOUS_ID, anonymousId)
317
+ }
318
+ if (distinctId.isNotEmpty()) {
319
+ preferences.setValue(DISTINCT_ID, distinctId)
320
+ }
321
+ }
322
+ }
323
+
324
+ @ReactMethod
325
+ fun startRecording(
326
+ resumeCurrent: Boolean,
327
+ promise: Promise,
328
+ ) {
329
+ try {
330
+ Txlemetry.startSessionReplay(resumeCurrent)
331
+ } catch (e: Throwable) {
332
+ logError("startRecording", e)
333
+ } finally {
334
+ promise.resolve(null)
335
+ }
336
+ }
337
+
338
+ @ReactMethod
339
+ fun stopRecording(promise: Promise) {
340
+ try {
341
+ Txlemetry.stopSessionReplay()
342
+ } catch (e: Throwable) {
343
+ logError("stopRecording", e)
344
+ } finally {
345
+ promise.resolve(null)
346
+ }
347
+ }
348
+
349
+ @ReactMethod
350
+ fun addExceptionStep(
351
+ message: String,
352
+ properties: ReadableMap?,
353
+ promise: Promise,
354
+ ) {
355
+ try {
356
+ // ReadableMap.toHashMap() is HashMap<String, Any?>; the native API takes Map<String, Any>?.
357
+ @Suppress("UNCHECKED_CAST")
358
+ val nativeProperties = properties?.toHashMap() as Map<String, Any>?
359
+ Txlemetry.addExceptionStep(message, nativeProperties)
360
+ } catch (e: Throwable) {
361
+ logError("addExceptionStep", e)
362
+ } finally {
363
+ promise.resolve(null)
364
+ }
365
+ }
366
+
367
+ private fun getMap(
368
+ map: ReadableMap?,
369
+ key: String,
370
+ ): ReadableMap? =
371
+ runCatching {
372
+ if (map != null && map.hasKey(key) && !map.isNull(key)) {
373
+ map.getMap(key)
374
+ } else {
375
+ null
376
+ }
377
+ }.getOrNull()
378
+
379
+ private fun getString(
380
+ map: ReadableMap?,
381
+ key: String,
382
+ default: String,
383
+ ): String = runCatching { if (hasKey(map, key)) map?.getString(key) ?: default else default }.getOrDefault(default)
384
+
385
+ private fun getInt(
386
+ map: ReadableMap?,
387
+ key: String,
388
+ default: Int,
389
+ ): Int = runCatching { if (hasKey(map, key)) map?.getInt(key) ?: default else default }.getOrDefault(default)
390
+
391
+ private fun getDoubleOrNull(
392
+ map: ReadableMap?,
393
+ key: String,
394
+ ): Double? = runCatching { if (hasKey(map, key)) map?.getDouble(key) else null }.getOrNull()
395
+
396
+ private fun logError(
397
+ method: String,
398
+ error: Throwable,
399
+ ) {
400
+ Log.println(Log.ERROR, TXLEMETRY_TAG, "Method $method, error: $error")
401
+ }
402
+
403
+ // These reject instead of this module's usual swallow-and-resolve convention: a failed
404
+ // registration is a distinct signal, not a silent success. The JS layer's public methods
405
+ // never throw, so the rejection is what gives it something to log.
406
+ @ReactMethod
407
+ fun registerPushNotificationToken(
408
+ deviceToken: String?,
409
+ appId: String?,
410
+ promise: Promise,
411
+ ) {
412
+ try {
413
+ if (deviceToken.isNullOrBlank()) {
414
+ // A blank token is dropped silently by the native SDK, so surface it here
415
+ // instead of reporting false success.
416
+ promise.reject(
417
+ PUSH_ERROR_CODE,
418
+ "registerPushNotificationToken: deviceToken is blank; token not registered.",
419
+ )
420
+ return
421
+ }
422
+ val resolvedAppId = appId?.trim()?.takeIf { it.isNotEmpty() } ?: firebaseProjectId
423
+ if (resolvedAppId.isNullOrEmpty()) {
424
+ val message =
425
+ "registerPushNotificationToken: no appId provided and no Firebase project id " +
426
+ "could be resolved, skipping. Pass appId explicitly if this app does not use Firebase."
427
+ Log.w(TXLEMETRY_TAG, message)
428
+ promise.reject(PUSH_ERROR_CODE, message)
429
+ return
430
+ }
431
+ Txlemetry.registerPushNotificationToken(deviceToken, resolvedAppId)
432
+ promise.resolve(null)
433
+ } catch (e: Throwable) {
434
+ logError("registerPushNotificationToken", e)
435
+ promise.reject(PUSH_ERROR_CODE, e)
436
+ }
437
+ }
438
+
439
+ @ReactMethod
440
+ fun unregisterPushNotificationToken(promise: Promise) {
441
+ try {
442
+ Txlemetry.unregisterPushNotificationToken()
443
+ promise.resolve(null)
444
+ } catch (e: Throwable) {
445
+ logError("unregisterPushNotificationToken", e)
446
+ promise.reject(PUSH_ERROR_CODE, e)
447
+ }
448
+ }
449
+
450
+ @ReactMethod
451
+ fun capturePushNotificationOpened(
452
+ properties: ReadableMap,
453
+ promise: Promise,
454
+ ) {
455
+ try {
456
+ // subtitle is iOS-only; txlemetry-android's capturePushNotificationOpened has no
457
+ // subtitle parameter, so JS's value is dropped here.
458
+ val title = if (hasKey(properties, "title")) properties.getString("title") else null
459
+ val body = if (hasKey(properties, "body")) properties.getString("body") else null
460
+ val payload = if (hasKey(properties, "payload")) properties.getMap("payload")?.toHashMap() else null
461
+ val action = if (hasKey(properties, "action")) properties.getString("action") else null
462
+ Txlemetry.capturePushNotificationOpened(title, body, payload, action)
463
+ promise.resolve(null)
464
+ } catch (e: Throwable) {
465
+ logError("capturePushNotificationOpened", e)
466
+ promise.reject(PUSH_ERROR_CODE, e)
467
+ }
468
+ }
469
+
470
+ // txlemetry-android's open-capture integration registers ActivityLifecycleCallbacks during
471
+ // setup(), which the bridge reaches only after the launch Activity was created — so the
472
+ // cold-start tray tap it exists for is the one creation it can never observe here. Read
473
+ // the launch intent directly, then strip the marker so the integration (or a re-run)
474
+ // can't capture the same tap again from this intent object.
475
+ private fun captureColdStartPushOpenIfNeeded(config: TxlemetryAndroidConfig) {
476
+ if (!config.capturePushNotificationOpened) {
477
+ return
478
+ }
479
+ val intent = reactApplicationContext.currentActivity?.intent ?: return
480
+ // A relaunch from recents redelivers the original tray intent to a fresh activity;
481
+ // capturing it would count a days-old tap as a new open.
482
+ if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) {
483
+ return
484
+ }
485
+ try {
486
+ intent.getStringExtra(GOOGLE_MESSAGE_ID) ?: return
487
+ // Unmarshalling extras throws BadParcelableException on a Parcelable class this
488
+ // classloader lacks; read before stripping the marker so a failed read leaves the
489
+ // intent as the native integration expects it.
490
+ val payload =
491
+ intent.extras?.let { bundle ->
492
+ bundle.keySet().associateWith { key ->
493
+ @Suppress("DEPRECATION")
494
+ bundle.get(key)
495
+ }
496
+ }
497
+ intent.removeExtra(GOOGLE_MESSAGE_ID)
498
+ Txlemetry.capturePushNotificationOpened(null, null, payload, null)
499
+ } catch (e: Throwable) {
500
+ logError("capturePushNotificationOpened", e)
501
+ }
502
+ }
503
+
504
+ @ReactMethod
505
+ fun providePushIdentityToken(
506
+ requestId: String?,
507
+ token: String?,
508
+ promise: Promise,
509
+ ) {
510
+ try {
511
+ if (requestId != null) {
512
+ pushIdentityCompletions.remove(requestId)?.invoke(token)
513
+ }
514
+ } catch (e: Throwable) {
515
+ logError("providePushIdentityToken", e)
516
+ } finally {
517
+ promise.resolve(null)
518
+ }
519
+ }
520
+
521
+ // Required by NativeEventEmitter on the old architecture; events are broadcast via
522
+ // RCTDeviceEventEmitter, so there is nothing to do here.
523
+ @ReactMethod
524
+ fun addListener(eventName: String?) = Unit
525
+
526
+ @ReactMethod
527
+ fun removeListeners(count: Int) = Unit
528
+
529
+ override fun invalidate() {
530
+ if (pushModule === this) {
531
+ // Decline mints fast after teardown instead of stalling the native 10s watchdog.
532
+ pushModule = null
533
+ }
534
+ super.invalidate()
535
+ }
536
+
537
+ companion object {
538
+ const val NAME = "TxlemetryReactNativePlugin"
539
+ const val TXLEMETRY_TAG = "Txlemetry"
540
+
541
+ // FCM stamps this extra on the tray-tap launch intent; mirrors txlemetry-android's
542
+ // TxlemetryActivityLifecycleCallbackIntegration.
543
+ private const val GOOGLE_MESSAGE_ID = "google.message_id"
544
+
545
+ // Default session replay configuration values
546
+ const val DEFAULT_MASK_ALL_TEXT_INPUTS = true
547
+ const val DEFAULT_MASK_ALL_IMAGES = true
548
+ const val DEFAULT_CAPTURE_LOG = true
549
+ const val DEFAULT_FLUSH_AT = 20
550
+ const val DEFAULT_THROTTLE_DELAY_MS = 1000
551
+
552
+ private const val PUSH_ERROR_CODE = "TxlemetryReactNativePluginError"
553
+ private const val PUSH_IDENTITY_EVENT = "TxlemetryPushIdentityRequest"
554
+ private const val PUSH_IDENTITY_REPLY_TTL_MS = 15_000L
555
+
556
+ // Modules die on every bridge reload, so the provider closure resolves the live
557
+ // module through this static reference at call time, not a captured setup-time one.
558
+ @Volatile
559
+ private var pushModule: TxlemetryReactNativePluginModule? = null
560
+
561
+ private val pushIdentityCompletions = java.util.concurrent.ConcurrentHashMap<String, (String?) -> Unit>()
562
+
563
+ private fun requestPushIdentityToken(
564
+ distinctId: String,
565
+ appId: String,
566
+ completion: (String?) -> Unit,
567
+ ) {
568
+ val module = pushModule
569
+ val context = module?.reactApplicationContext
570
+ if (module == null || context == null || !context.hasActiveReactInstance()) {
571
+ declinePushIdentity(completion, "no React instance attached")
572
+ return
573
+ }
574
+ val requestId = UUID.randomUUID().toString()
575
+ pushIdentityCompletions[requestId] = completion
576
+ try {
577
+ val params =
578
+ Arguments.createMap().apply {
579
+ putString("requestId", requestId)
580
+ putString("distinctId", distinctId)
581
+ putString("appId", appId)
582
+ }
583
+ context
584
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
585
+ .emit(PUSH_IDENTITY_EVENT, params)
586
+ } catch (e: Throwable) {
587
+ pushIdentityCompletions.remove(requestId)
588
+ declinePushIdentity(completion, "failed to reach JS: ${e.message}")
589
+ return
590
+ }
591
+ // The native SDK's own 10s mint watchdog handles the fallback; this only drops the
592
+ // entry so a late JS reply is ignored and the completion doesn't leak.
593
+ UiThreadUtil.runOnUiThread({ pushIdentityCompletions.remove(requestId) }, PUSH_IDENTITY_REPLY_TTL_MS)
594
+ }
595
+
596
+ // A null identity token sends the request unauthenticated, which a project requiring
597
+ // identity verification rejects server-side. Log the reason so that failure is
598
+ // greppable and distinct from a host that deliberately returned null.
599
+ private fun declinePushIdentity(
600
+ completion: (String?) -> Unit,
601
+ reason: String,
602
+ ) {
603
+ Log.w(TXLEMETRY_TAG, "Push subscription will be sent unauthenticated: $reason")
604
+ completion(null)
605
+ }
606
+
607
+ @Volatile private var cachedFirebaseProjectId: String? = null
608
+
609
+ // No Firebase dependency here, so the project id is looked up reflectively and any
610
+ // failure (class missing, Firebase not initialized) is swallowed. Only a successful lookup
611
+ // is cached: getInstance() throws until Firebase initializes, so memoizing the null would
612
+ // strand every later token-refresh call with no resolvable appId for the process lifetime.
613
+ private val firebaseProjectId: String?
614
+ get() =
615
+ cachedFirebaseProjectId
616
+ ?: try {
617
+ val firebaseAppClass = Class.forName("com.google.firebase.FirebaseApp")
618
+ val firebaseApp = firebaseAppClass.getMethod("getInstance").invoke(null)
619
+ val options = firebaseAppClass.getMethod("getOptions").invoke(firebaseApp)
620
+ (options?.javaClass?.getMethod("getProjectId")?.invoke(options) as? String)?.also {
621
+ cachedFirebaseProjectId = it
622
+ }
623
+ } catch (e: Throwable) {
624
+ null
625
+ }
626
+ }
627
+ }
628
+
629
+ private fun hasKey(
630
+ map: ReadableMap?,
631
+ key: String,
632
+ ): Boolean = runCatching { map != null && map.hasKey(key) && !map.isNull(key) }.getOrDefault(false)
633
+
634
+ internal fun getBoolean(
635
+ map: ReadableMap?,
636
+ key: String,
637
+ default: Boolean,
638
+ ): Boolean = runCatching { if (hasKey(map, key)) map?.getBoolean(key) ?: default else default }.getOrDefault(default)