@posthog/react-native-plugin 2.2.3 → 2.3.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.
@@ -85,6 +85,6 @@ dependencies {
85
85
  //noinspection GradleDynamicVersion
86
86
  implementation "com.facebook.react:react-native:+"
87
87
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
88
- implementation "com.posthog:posthog-android:3.54.0"
88
+ implementation "com.posthog:posthog-android:3.58.0"
89
89
  }
90
90
 
@@ -1,6 +1,8 @@
1
1
  package com.posthogreactnativeplugin
2
2
 
3
+ import android.content.Intent
3
4
  import android.util.Log
5
+ import com.facebook.react.bridge.Arguments
4
6
  import com.facebook.react.bridge.Promise
5
7
  import com.facebook.react.bridge.ReactApplicationContext
6
8
  import com.facebook.react.bridge.ReactContextBaseJavaModule
@@ -8,6 +10,7 @@ import com.facebook.react.bridge.ReactMethod
8
10
  import com.facebook.react.bridge.ReadableMap
9
11
  import com.facebook.react.bridge.UiThreadUtil
10
12
  import com.facebook.react.common.JavascriptException
13
+ import com.facebook.react.modules.core.DeviceEventManagerModule
11
14
  import com.posthog.PostHog
12
15
  import com.posthog.PostHogConfig
13
16
  import com.posthog.android.PostHogAndroid
@@ -42,6 +45,7 @@ class PosthogReactNativePluginModule(
42
45
  decideReplayConfig = getMap(sessionReplayConfig, "decideReplayConfig"),
43
46
  nativeErrorTrackingAutocapture = getBoolean(errorTrackingConfig, "nativeAutocapture", false),
44
47
  exceptionStepsConfig = getMap(errorTrackingConfig, "exceptionSteps"),
48
+ pushConfig = getMap(pluginConfig, "push"),
45
49
  promise = promise,
46
50
  )
47
51
  }
@@ -63,6 +67,7 @@ class PosthogReactNativePluginModule(
63
67
  decideReplayConfig = decideReplayConfig,
64
68
  nativeErrorTrackingAutocapture = false,
65
69
  exceptionStepsConfig = null,
70
+ pushConfig = null,
66
71
  promise = promise,
67
72
  )
68
73
  }
@@ -76,6 +81,7 @@ class PosthogReactNativePluginModule(
76
81
  decideReplayConfig: ReadableMap?,
77
82
  nativeErrorTrackingAutocapture: Boolean,
78
83
  exceptionStepsConfig: ReadableMap?,
84
+ pushConfig: ReadableMap?,
79
85
  promise: Promise,
80
86
  ) {
81
87
  val initRunnable =
@@ -92,17 +98,23 @@ class PosthogReactNativePluginModule(
92
98
  val anonymousId = getString(sdkOptions, "anonymousId", "")
93
99
  val theSdkVersion = getString(sdkOptions, "sdkVersion", "")
94
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)
95
104
 
96
105
  // Forward custom headers (e.g. Authorization for a reverse proxy) so the native SDK
97
106
  // attaches them to the requests it sends directly (session replay, crash uploads).
98
107
  val theRequestHeaders =
99
- getMap(sdkOptions, "requestHeaders")?.toHashMap()
108
+ getMap(sdkOptions, "requestHeaders")
109
+ ?.toHashMap()
100
110
  ?.filterValues { it is String }
101
111
  ?.mapValues { it.value as String }
102
112
 
103
113
  val config =
104
114
  PostHogAndroidConfig(apiKey, host).apply {
105
115
  debug = debugValue
116
+ optOut = theOptOut
117
+ preloadFeatureFlags = thePreloadFeatureFlags
106
118
  captureDeepLinks = false
107
119
  captureApplicationLifecycleEvents = false
108
120
  captureScreenViews = false
@@ -112,8 +124,10 @@ class PosthogReactNativePluginModule(
112
124
 
113
125
  // Keep the native exception-steps buffer aligned with the JS layer (one logical buffer).
114
126
  // Absent keys fall back to the native defaults the helpers receive.
115
- errorTrackingConfig.exceptionSteps.enabled = getBoolean(exceptionStepsConfig, "enabled", errorTrackingConfig.exceptionSteps.enabled)
116
- errorTrackingConfig.exceptionSteps.maxBytes = getInt(exceptionStepsConfig, "maxBytes", errorTrackingConfig.exceptionSteps.maxBytes)
127
+ errorTrackingConfig.exceptionSteps.enabled =
128
+ getBoolean(exceptionStepsConfig, "enabled", errorTrackingConfig.exceptionSteps.enabled)
129
+ errorTrackingConfig.exceptionSteps.maxBytes =
130
+ getInt(exceptionStepsConfig, "maxBytes", errorTrackingConfig.exceptionSteps.maxBytes)
117
131
 
118
132
  // React Native rethrows fatal JS errors natively as JavascriptException.
119
133
  // The JS layer already captured them, so drop the native duplicate.
@@ -130,7 +144,10 @@ class PosthogReactNativePluginModule(
130
144
  val throttleDelayMs =
131
145
  when {
132
146
  hasKey(sdkReplayConfig, "throttleDelayMs") -> getInt(sdkReplayConfig, "throttleDelayMs", DEFAULT_THROTTLE_DELAY_MS)
133
- hasKey(sdkReplayConfig, "androidDebouncerDelayMs") -> getInt(sdkReplayConfig, "androidDebouncerDelayMs", DEFAULT_THROTTLE_DELAY_MS)
147
+ hasKey(
148
+ sdkReplayConfig,
149
+ "androidDebouncerDelayMs",
150
+ ) -> getInt(sdkReplayConfig, "androidDebouncerDelayMs", DEFAULT_THROTTLE_DELAY_MS)
134
151
  else -> DEFAULT_THROTTLE_DELAY_MS
135
152
  }
136
153
 
@@ -147,6 +164,25 @@ class PosthogReactNativePluginModule(
147
164
  snapshotEndpoint = endpoint
148
165
  }
149
166
 
167
+ // Only set when present: the legacy start() path predates push, and there the
168
+ // native defaults (both true) must win, matching posthog-android on its own.
169
+ if (hasKey(pushConfig, "capturePushNotificationSubscriptions")) {
170
+ capturePushNotificationSubscriptions =
171
+ getBoolean(pushConfig, "capturePushNotificationSubscriptions", true)
172
+ }
173
+ if (hasKey(pushConfig, "capturePushNotificationOpened")) {
174
+ capturePushNotificationOpened = getBoolean(pushConfig, "capturePushNotificationOpened", true)
175
+ }
176
+
177
+ // Installed only when JS asked for it: an uninvited bridging provider would
178
+ // change how the native SDK handles a 401 on the subscription call.
179
+ if (getBoolean(pushConfig, "pushIdentityProviderEnabled", false)) {
180
+ pushModule = this@PosthogReactNativePluginModule
181
+ pushIdentityProvider = { distinctId, appId, completion ->
182
+ requestPushIdentityToken(distinctId, appId, completion)
183
+ }
184
+ }
185
+
150
186
  if (theSdkVersion.isNotEmpty()) {
151
187
  sdkName = "posthog-react-native"
152
188
  sdkVersion = theSdkVersion
@@ -155,6 +191,8 @@ class PosthogReactNativePluginModule(
155
191
  PostHogAndroid.setup(context, config)
156
192
 
157
193
  setIdentify(config.cachePreferences, distinctId, anonymousId)
194
+
195
+ captureColdStartPushOpenIfNeeded(config)
158
196
  } catch (e: Throwable) {
159
197
  logError(method, e)
160
198
  } finally {
@@ -222,6 +260,50 @@ class PosthogReactNativePluginModule(
222
260
  }
223
261
  }
224
262
 
263
+ // Calls the native SDK rather than writing preferences like identify() does: reset() is what
264
+ // unregisters the logged-out user's push subscription and re-registers under the new identity.
265
+ @ReactMethod
266
+ fun reset(
267
+ distinctId: String,
268
+ anonymousId: String,
269
+ promise: Promise,
270
+ ) {
271
+ try {
272
+ PostHog.reset()
273
+ // Native reset() mints its own anonymous id; overwrite it with the JS one so the two
274
+ // SDKs stay on the same identity. Must run after reset(), which needs the pre-reset
275
+ // distinctId to know which subscription to unregister. Known gap (as on iOS): native's
276
+ // async push re-registration can read the identity before this write lands and register
277
+ // under a throwaway id; retryPending() converges it on the next flush.
278
+ setIdentify(PostHog.getConfig<PostHogConfig>()?.cachePreferences, distinctId, anonymousId)
279
+ } catch (e: Throwable) {
280
+ logError("reset", e)
281
+ } finally {
282
+ promise.resolve(null)
283
+ }
284
+ }
285
+
286
+ // Runtime consent changes must reach native: it persists its own opt-out flag and only reads
287
+ // the JS value at setup(), so a refreshed FCM token could otherwise auto-register after the
288
+ // user opted out. optIn() also resumes deferred push work on the next flush.
289
+ @ReactMethod
290
+ fun setOptOut(
291
+ optOut: Boolean,
292
+ promise: Promise,
293
+ ) {
294
+ try {
295
+ if (optOut) {
296
+ PostHog.optOut()
297
+ } else {
298
+ PostHog.optIn()
299
+ }
300
+ } catch (e: Throwable) {
301
+ logError("setOptOut", e)
302
+ } finally {
303
+ promise.resolve(null)
304
+ }
305
+ }
306
+
225
307
  private fun setIdentify(
226
308
  cachePreferences: PostHogPreferences?,
227
309
  distinctId: String,
@@ -327,15 +409,228 @@ class PosthogReactNativePluginModule(
327
409
  Log.println(Log.ERROR, POSTHOG_TAG, "Method $method, error: $error")
328
410
  }
329
411
 
412
+ // These reject instead of this module's usual swallow-and-resolve convention: a failed
413
+ // registration is a distinct signal, not a silent success. The JS layer's public methods
414
+ // never throw, so the rejection is what gives it something to log.
415
+ @ReactMethod
416
+ fun registerPushNotificationToken(
417
+ deviceToken: String?,
418
+ appId: String?,
419
+ promise: Promise,
420
+ ) {
421
+ try {
422
+ if (deviceToken.isNullOrBlank()) {
423
+ // A blank token is dropped silently by the native SDK, so surface it here
424
+ // instead of reporting false success.
425
+ promise.reject(
426
+ PUSH_ERROR_CODE,
427
+ "registerPushNotificationToken: deviceToken is blank; token not registered.",
428
+ )
429
+ return
430
+ }
431
+ val resolvedAppId = appId?.trim()?.takeIf { it.isNotEmpty() } ?: firebaseProjectId
432
+ if (resolvedAppId.isNullOrEmpty()) {
433
+ val message =
434
+ "registerPushNotificationToken: no appId provided and no Firebase project id " +
435
+ "could be resolved, skipping. Pass appId explicitly if this app does not use Firebase."
436
+ Log.w(POSTHOG_TAG, message)
437
+ promise.reject(PUSH_ERROR_CODE, message)
438
+ return
439
+ }
440
+ PostHog.registerPushNotificationToken(deviceToken, resolvedAppId)
441
+ promise.resolve(null)
442
+ } catch (e: Throwable) {
443
+ logError("registerPushNotificationToken", e)
444
+ promise.reject(PUSH_ERROR_CODE, e)
445
+ }
446
+ }
447
+
448
+ @ReactMethod
449
+ fun unregisterPushNotificationToken(promise: Promise) {
450
+ try {
451
+ PostHog.unregisterPushNotificationToken()
452
+ promise.resolve(null)
453
+ } catch (e: Throwable) {
454
+ logError("unregisterPushNotificationToken", e)
455
+ promise.reject(PUSH_ERROR_CODE, e)
456
+ }
457
+ }
458
+
459
+ @ReactMethod
460
+ fun capturePushNotificationOpened(
461
+ properties: ReadableMap,
462
+ promise: Promise,
463
+ ) {
464
+ try {
465
+ // subtitle is iOS-only; posthog-android's capturePushNotificationOpened has no
466
+ // subtitle parameter, so JS's value is dropped here.
467
+ val title = if (hasKey(properties, "title")) properties.getString("title") else null
468
+ val body = if (hasKey(properties, "body")) properties.getString("body") else null
469
+ val payload = if (hasKey(properties, "payload")) properties.getMap("payload")?.toHashMap() else null
470
+ val action = if (hasKey(properties, "action")) properties.getString("action") else null
471
+ PostHog.capturePushNotificationOpened(title, body, payload, action)
472
+ promise.resolve(null)
473
+ } catch (e: Throwable) {
474
+ logError("capturePushNotificationOpened", e)
475
+ promise.reject(PUSH_ERROR_CODE, e)
476
+ }
477
+ }
478
+
479
+ // posthog-android's open-capture integration registers ActivityLifecycleCallbacks during
480
+ // setup(), which the bridge reaches only after the launch Activity was created — so the
481
+ // cold-start tray tap it exists for is the one creation it can never observe here. Read
482
+ // the launch intent directly, then strip the marker so the integration (or a re-run)
483
+ // can't capture the same tap again from this intent object.
484
+ private fun captureColdStartPushOpenIfNeeded(config: PostHogAndroidConfig) {
485
+ if (!config.capturePushNotificationOpened) {
486
+ return
487
+ }
488
+ val intent = currentActivity?.intent ?: return
489
+ // A relaunch from recents redelivers the original tray intent to a fresh activity;
490
+ // capturing it would count a days-old tap as a new open.
491
+ if (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0) {
492
+ return
493
+ }
494
+ try {
495
+ intent.getStringExtra(GOOGLE_MESSAGE_ID) ?: return
496
+ // Unmarshalling extras throws BadParcelableException on a Parcelable class this
497
+ // classloader lacks; read before stripping the marker so a failed read leaves the
498
+ // intent as the native integration expects it.
499
+ val payload =
500
+ intent.extras?.let { bundle ->
501
+ bundle.keySet().associateWith { key ->
502
+ @Suppress("DEPRECATION")
503
+ bundle.get(key)
504
+ }
505
+ }
506
+ intent.removeExtra(GOOGLE_MESSAGE_ID)
507
+ PostHog.capturePushNotificationOpened(null, null, payload, null)
508
+ } catch (e: Throwable) {
509
+ logError("capturePushNotificationOpened", e)
510
+ }
511
+ }
512
+
513
+ @ReactMethod
514
+ fun providePushIdentityToken(
515
+ requestId: String?,
516
+ token: String?,
517
+ promise: Promise,
518
+ ) {
519
+ try {
520
+ if (requestId != null) {
521
+ pushIdentityCompletions.remove(requestId)?.invoke(token)
522
+ }
523
+ } catch (e: Throwable) {
524
+ logError("providePushIdentityToken", e)
525
+ } finally {
526
+ promise.resolve(null)
527
+ }
528
+ }
529
+
530
+ // Required by NativeEventEmitter on the old architecture; events are broadcast via
531
+ // RCTDeviceEventEmitter, so there is nothing to do here.
532
+ @ReactMethod
533
+ fun addListener(eventName: String?) = Unit
534
+
535
+ @ReactMethod
536
+ fun removeListeners(count: Int) = Unit
537
+
538
+ override fun invalidate() {
539
+ if (pushModule === this) {
540
+ // Decline mints fast after teardown instead of stalling the native 10s watchdog.
541
+ pushModule = null
542
+ }
543
+ super.invalidate()
544
+ }
545
+
330
546
  companion object {
331
547
  const val NAME = "PosthogReactNativePlugin"
332
548
  const val POSTHOG_TAG = "PostHog"
333
549
 
550
+ // FCM stamps this extra on the tray-tap launch intent; mirrors posthog-android's
551
+ // PostHogActivityLifecycleCallbackIntegration.
552
+ private const val GOOGLE_MESSAGE_ID = "google.message_id"
553
+
334
554
  // Default session replay configuration values
335
555
  const val DEFAULT_MASK_ALL_TEXT_INPUTS = true
336
556
  const val DEFAULT_MASK_ALL_IMAGES = true
337
557
  const val DEFAULT_CAPTURE_LOG = true
338
558
  const val DEFAULT_FLUSH_AT = 20
339
559
  const val DEFAULT_THROTTLE_DELAY_MS = 1000
560
+
561
+ private const val PUSH_ERROR_CODE = "PosthogReactNativePluginError"
562
+ private const val PUSH_IDENTITY_EVENT = "PostHogPushIdentityRequest"
563
+ private const val PUSH_IDENTITY_REPLY_TTL_MS = 15_000L
564
+
565
+ // Modules die on every bridge reload, so the provider closure resolves the live
566
+ // module through this static reference at call time, not a captured setup-time one.
567
+ @Volatile
568
+ private var pushModule: PosthogReactNativePluginModule? = null
569
+
570
+ private val pushIdentityCompletions = java.util.concurrent.ConcurrentHashMap<String, (String?) -> Unit>()
571
+
572
+ private fun requestPushIdentityToken(
573
+ distinctId: String,
574
+ appId: String,
575
+ completion: (String?) -> Unit,
576
+ ) {
577
+ val module = pushModule
578
+ val context = module?.reactApplicationContext
579
+ if (module == null || context == null || !context.hasActiveReactInstance()) {
580
+ declinePushIdentity(completion, "no React instance attached")
581
+ return
582
+ }
583
+ val requestId = UUID.randomUUID().toString()
584
+ pushIdentityCompletions[requestId] = completion
585
+ try {
586
+ val params =
587
+ Arguments.createMap().apply {
588
+ putString("requestId", requestId)
589
+ putString("distinctId", distinctId)
590
+ putString("appId", appId)
591
+ }
592
+ context
593
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
594
+ .emit(PUSH_IDENTITY_EVENT, params)
595
+ } catch (e: Throwable) {
596
+ pushIdentityCompletions.remove(requestId)
597
+ declinePushIdentity(completion, "failed to reach JS: ${e.message}")
598
+ return
599
+ }
600
+ // The native SDK's own 10s mint watchdog handles the fallback; this only drops the
601
+ // entry so a late JS reply is ignored and the completion doesn't leak.
602
+ UiThreadUtil.runOnUiThread({ pushIdentityCompletions.remove(requestId) }, PUSH_IDENTITY_REPLY_TTL_MS)
603
+ }
604
+
605
+ // A null identity token sends the request unauthenticated, which a project requiring
606
+ // identity verification rejects server-side. Log the reason so that failure is
607
+ // greppable and distinct from a host that deliberately returned null.
608
+ private fun declinePushIdentity(
609
+ completion: (String?) -> Unit,
610
+ reason: String,
611
+ ) {
612
+ Log.w(POSTHOG_TAG, "Push subscription will be sent unauthenticated: $reason")
613
+ completion(null)
614
+ }
615
+
616
+ @Volatile private var cachedFirebaseProjectId: String? = null
617
+
618
+ // No Firebase dependency here, so the project id is looked up reflectively and any
619
+ // failure (class missing, Firebase not initialized) is swallowed. Only a successful lookup
620
+ // is cached: getInstance() throws until Firebase initializes, so memoizing the null would
621
+ // strand every later token-refresh call with no resolvable appId for the process lifetime.
622
+ private val firebaseProjectId: String?
623
+ get() =
624
+ cachedFirebaseProjectId
625
+ ?: try {
626
+ val firebaseAppClass = Class.forName("com.google.firebase.FirebaseApp")
627
+ val firebaseApp = firebaseAppClass.getMethod("getInstance").invoke(null)
628
+ val options = firebaseAppClass.getMethod("getOptions").invoke(firebaseApp)
629
+ (options?.javaClass?.getMethod("getProjectId")?.invoke(options) as? String)?.also {
630
+ cachedFirebaseProjectId = it
631
+ }
632
+ } catch (e: Throwable) {
633
+ null
634
+ }
340
635
  }
341
636
  }
@@ -1,2 +1,3 @@
1
1
  #import <React/RCTBridgeModule.h>
2
2
  #import <React/RCTViewManager.h>
3
+ #import <React/RCTEventEmitter.h>
@@ -1,6 +1,7 @@
1
1
  #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
2
3
 
3
- @interface RCT_EXTERN_MODULE(PosthogReactNativePlugin, NSObject)
4
+ @interface RCT_EXTERN_MODULE(PosthogReactNativePlugin, RCTEventEmitter)
4
5
 
5
6
  RCT_EXTERN_METHOD(setup:(NSString)sessionId
6
7
  withSdkOptions:(NSDictionary)sdkOptions
@@ -42,6 +43,32 @@ RCT_EXTERN_METHOD(addExceptionStep:(NSString)message
42
43
  withResolver:(RCTPromiseResolveBlock)resolve
43
44
  withRejecter:(RCTPromiseRejectBlock)reject)
44
45
 
46
+ RCT_EXTERN_METHOD(reset:(NSString)distinctId
47
+ withAnonymousId:(NSString)anonymousId
48
+ withResolver:(RCTPromiseResolveBlock)resolve
49
+ withRejecter:(RCTPromiseRejectBlock)reject)
50
+
51
+ RCT_EXTERN_METHOD(registerPushNotificationToken:(NSString)deviceToken
52
+ withAppId:(NSString)appId
53
+ withResolver:(RCTPromiseResolveBlock)resolve
54
+ withRejecter:(RCTPromiseRejectBlock)reject)
55
+
56
+ RCT_EXTERN_METHOD(unregisterPushNotificationToken:(RCTPromiseResolveBlock)resolve
57
+ withRejecter:(RCTPromiseRejectBlock)reject)
58
+
59
+ RCT_EXTERN_METHOD(setOptOut:(BOOL)optOut
60
+ withResolver:(RCTPromiseResolveBlock)resolve
61
+ withRejecter:(RCTPromiseRejectBlock)reject)
62
+
63
+ RCT_EXTERN_METHOD(capturePushNotificationOpened:(NSDictionary)properties
64
+ withResolver:(RCTPromiseResolveBlock)resolve
65
+ withRejecter:(RCTPromiseRejectBlock)reject)
66
+
67
+ RCT_EXTERN_METHOD(providePushIdentityToken:(NSString)requestId
68
+ withToken:(NSString)token
69
+ withResolver:(RCTPromiseResolveBlock)resolve
70
+ withRejecter:(RCTPromiseRejectBlock)reject)
71
+
45
72
  + (BOOL)requiresMainQueueSetup
46
73
  {
47
74
  return NO;