@txlemetry/react-native-plugin 2.5.1
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/LICENSE +353 -0
- package/README.md +9 -0
- package/android/build.gradle +91 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/AndroidManifestNew.xml +2 -0
- package/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt +638 -0
- package/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginPackage.kt +13 -0
- package/ios/Package.swift +70 -0
- package/ios/PosthogReactNativePlugin-Bridging-Header.h +3 -0
- package/ios/PosthogReactNativePlugin.mm +77 -0
- package/ios/PosthogReactNativePlugin.swift +474 -0
- package/lib/commonjs/index.js +143 -0
- package/lib/module/index.js +124 -0
- package/lib/typescript/commonjs/package.json +3 -0
- package/lib/typescript/commonjs/src/index.d.ts +92 -0
- package/lib/typescript/module/package.json +3 -0
- package/lib/typescript/module/src/index.d.ts +92 -0
- package/package.json +121 -0
- package/src/index.tsx +238 -0
- package/txlemetry-react-native-plugin.podspec +69 -0
|
@@ -0,0 +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)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
package com.posthogreactnativeplugin
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager
|
|
7
|
+
|
|
8
|
+
class PosthogReactNativePluginPackage : ReactPackage {
|
|
9
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
|
10
|
+
listOf(PosthogReactNativePluginModule(reactContext))
|
|
11
|
+
|
|
12
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
|
|
13
|
+
}
|