clintplay-react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # clintplay-react-native
2
+
3
+ Session capture SDK for React Native / Expo apps — companion to [`clintplay-sdk`](../sdk) (web). Captures lifecycle, screen dimensions, navigation, JS errors, network requests (Fetch & XHR/Axios), console output, touches (screen-relative & target-relative), and native visual wireframe replay on iOS and Android.
4
+
5
+ > **Status:** `0.1.0` — Production-ready JS runtime and Native TurboModule. Supports native view hierarchy traversal on iOS (`ios/ClintplayRN.mm`) and Android (`android/.../ClintplayRNModule.kt`), visual wireframe session replay, network interception for `fetch` and `XMLHttpRequest`/Axios, automatic navigation tracking, and intelligent auto-masking for sensitive fields.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add clintplay-react-native
11
+ # or
12
+ npm install clintplay-react-native
13
+ ```
14
+
15
+ Peer deps: `react >=18`, `react-native >=0.74`. Optional peer: `@react-native-async-storage/async-storage` (enables the offline retry queue when present). Tested against Expo SDK 54 / RN 0.81 with New Architecture enabled.
16
+
17
+ In your `app.json`, register the config plugin:
18
+
19
+ ```json
20
+ {
21
+ "expo": {
22
+ "plugins": ["clintplay-react-native"]
23
+ }
24
+ }
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```tsx
30
+ // app/_layout.tsx
31
+ import AsyncStorage from '@react-native-async-storage/async-storage';
32
+ import {
33
+ Clintplay,
34
+ ClintplayCaptureProvider,
35
+ ClintplayNavigationTracker,
36
+ } from 'clintplay-react-native';
37
+
38
+ Clintplay.init({
39
+ apiUrl: process.env.EXPO_PUBLIC_CLINTPLAY_URL!,
40
+ apiKey: process.env.EXPO_PUBLIC_CLINTPLAY_KEY,
41
+ asyncStorage: AsyncStorage, // enables offline retry queue
42
+ captureWireframe: true, // native visual replay snapshots
43
+ wireframeIntervalMs: 1000, // snapshot interval (default: 1000ms)
44
+ captureNetwork: true, // fetch + XMLHttpRequest/axios (on by default)
45
+ captureConsole: false, // opt-in console.warn/error
46
+ captureConsoleLog: false, // opt-in console.log (requires captureConsole: true)
47
+ // sampleRate: 1.0,
48
+ // debug: false,
49
+ });
50
+
51
+ export default function RootLayout() {
52
+ return (
53
+ <ClintplayCaptureProvider>
54
+ <ClintplayNavigationTracker />
55
+ <YourApp />
56
+ </ClintplayCaptureProvider>
57
+ );
58
+ }
59
+ ```
60
+
61
+ `init()` is a no-op when `apiUrl` is empty, so it is safe to call unconditionally in all environments.
62
+
63
+ ## API Reference
64
+
65
+ | Call | What it does |
66
+ | --- | --- |
67
+ | `Clintplay.init(config)` | Boot the SDK once at app start. Idempotent. |
68
+ | `Clintplay.identify(userId, traits?)` | Tag the current session with a user id and optional metadata. Pass `null` on logout. |
69
+ | `Clintplay.trackScreen(path, from?)` | Emit a screen navigation event manually. (Alternatively, use `<ClintplayNavigationTracker />` or `useClintplayNavigationTracking()`). |
70
+ | `Clintplay.reportError(err)` | Manually surface a caught error. Uncaught errors are captured automatically via `ErrorUtils.setGlobalHandler`. |
71
+ | `Clintplay.flush()` | Force-flush the event buffer (e.g. on backgrounding). |
72
+ | `Clintplay.stop()` | Tear down listeners, interceptors, and timers. |
73
+ | `Clintplay.current()` | Inspect the current session ID and user ID (for breadcrumbs or support tickets). |
74
+
75
+ ## Configuration Options
76
+
77
+ | Option | Type | Default | Description |
78
+ | --- | --- | --- | --- |
79
+ | `apiUrl` | `string` | *(required)* | Backend ingestion URL. If omitted or empty, `init()` safely no-ops. |
80
+ | `apiKey` | `string` | `undefined` | Optional Bearer auth token for ingestion batches. |
81
+ | `enabled` | `boolean` | `true` | Master killswitch. Set to `false` to disable all telemetry. |
82
+ | `sampleRate` | `number` | `1.0` | Probabilistic sampling rate between `0.0` and `1.0`. |
83
+ | `captureWireframe` | `boolean` | `false` | Enable native view-hierarchy visual wireframe capture for session replay. |
84
+ | `wireframeIntervalMs` | `number` | `1000` | Wireframe snapshot interval in milliseconds. |
85
+ | `captureNetwork` | `boolean` | `true` | Intercept `globalThis.fetch` and `XMLHttpRequest` (including Axios) requests. |
86
+ | `captureConsole` | `boolean` | `false` | Intercept `console.warn` and `console.error` logs. |
87
+ | `captureConsoleLog` | `boolean` | `false` | Intercept `console.log` logs when `captureConsole: true`. |
88
+ | `sanitizeUrl` | `(url: string) => string` | `stripQuery` | Custom URL sanitizer function for network requests. Default strips query strings. |
89
+ | `excludeEventTypes` | `EventType[]` | `[]` | Explicitly drop specific event types (`'wireframe'`, `'network'`, `'touch'`, `'log'`, etc.). |
90
+ | `flushIntervalMs` | `number` | `5000` | Periodic buffer flush interval. |
91
+ | `flushSize` | `number` | `50` | Buffer size threshold triggering an immediate flush. |
92
+ | `maxBufferSize` | `number` | `1000` | Ring buffer capacity before oldest events are dropped. |
93
+ | `asyncStorage` | `AsyncStorageLike` | `undefined` | Storage adapter (e.g., `@react-native-async-storage/async-storage`) for offline queue persistence. |
94
+ | `debug` | `boolean` | `false` | Enable verbose diagnostic logging prefixed with `[clintplay-rn]`. |
95
+
96
+ ## Capture Sources
97
+
98
+ | Event Type | Source | Default | Details |
99
+ | --- | --- | --- | --- |
100
+ | `meta` | SDK Bootstrap & Session Rotation | **On** | Session ID, SDK version, device platform, OS version. |
101
+ | `screen` | `Dimensions` listener | **On** | Screen width, height, scale factor, and orientation (`portrait`/`landscape`). |
102
+ | `lifecycle` | `AppState` listener | **On** | Application state transitions (`active`, `background`, `inactive`). |
103
+ | `error` | `ErrorUtils.setGlobalHandler` | **On** | Uncaught exceptions with message, stack trace, and fatal flag. Non-interfering with Sentry. |
104
+ | `network` | `fetch` & `XMLHttpRequest` / Axios | **On** | HTTP method, URL (sanitized), status code, duration, and errors. Protected against double-counting. |
105
+ | `navigation` | Navigation Trackers & `trackScreen` | **On (Tracker)** | Screen path changes (`to`, `from`). Automatic via `<ClintplayNavigationTracker />` or React Navigation listener. |
106
+ | `touch` | `<ClintplayCaptureProvider>` | **Provider** | Tap / touch gestures with both screen-relative coordinates (`screenX`, `screenY`) and target-relative coordinates (`x`, `y`). |
107
+ | `wireframe` | Native TurboModule | **Opt-in** | Visual UI hierarchy snapshots (view rects, styles, text, masking) across iOS and Android. |
108
+ | `log` | Console Interceptor | **Opt-in** | Console output (`warn`, `error`, and optional `log`). |
109
+ | `identify` | `Clintplay.identify()` | **Manual** | Associating sessions with authenticated users and custom traits. |
110
+
111
+ ## Navigation Tracking
112
+
113
+ ### Expo Router
114
+
115
+ Add `<ClintplayNavigationTracker />` at the root inside your layout:
116
+
117
+ ```tsx
118
+ // app/_layout.tsx
119
+ import { ClintplayCaptureProvider, ClintplayNavigationTracker } from 'clintplay-react-native';
120
+
121
+ export default function RootLayout() {
122
+ return (
123
+ <ClintplayCaptureProvider>
124
+ <ClintplayNavigationTracker />
125
+ <Slot />
126
+ </ClintplayCaptureProvider>
127
+ );
128
+ }
129
+ ```
130
+
131
+ The tracker uses `useSegments()` / `usePathname()` when running under Expo Router to emit navigation events automatically on route changes.
132
+
133
+ ### React Navigation
134
+
135
+ Use `createClintplayNavigationStateListener` on your `NavigationContainer`:
136
+
137
+ ```tsx
138
+ import { NavigationContainer } from '@react-navigation/native';
139
+ import { createClintplayNavigationStateListener } from 'clintplay-react-native';
140
+
141
+ const onStateChange = createClintplayNavigationStateListener();
142
+
143
+ export default function App() {
144
+ return (
145
+ <NavigationContainer onStateChange={onStateChange}>
146
+ <RootStackNavigator />
147
+ </NavigationContainer>
148
+ );
149
+ }
150
+ ```
151
+
152
+ Or use the hook directly:
153
+
154
+ ```tsx
155
+ import { useClintplayNavigationTracking } from 'clintplay-react-native';
156
+
157
+ function MyNavigationComponent() {
158
+ useClintplayNavigationTracking({
159
+ onNavigate: (to, from) => {
160
+ // custom handling if needed, Clintplay.trackScreen is called by default
161
+ },
162
+ });
163
+ return <NavigationStack />;
164
+ }
165
+ ```
166
+
167
+ ## Coordinate Tracking (`screenX`, `screenY`)
168
+
169
+ `<ClintplayCaptureProvider>` listens to bubble-phase touch responder events without intercepting touches from downstream buttons or lists.
170
+
171
+ Each touch event captures:
172
+ - `screenX`, `screenY`: Screen-relative coordinates (derived from `nativeEvent.pageX` / `nativeEvent.pageY`). These are ideal for session replay cursor rendering and heatmaps.
173
+ - `x`, `y`: Target-relative coordinates (derived from `nativeEvent.locationX` / `nativeEvent.locationY`).
174
+
175
+ ## Privacy, Masking & Redaction
176
+
177
+ ### Automatic Masking Heuristics
178
+
179
+ When `captureWireframe: true` is enabled, the native traversal engines on iOS and Android automatically redact sensitive views before building the wireframe payload:
180
+ - **iOS**: Views with `isSecureTextEntry == YES` on `UITextField`, or `textContentType` matching `password`, `newPassword`, `creditCardNumber`, or `oneTimeCode`.
181
+ - **Android**: `EditText` views with `inputType` matching password variations (`TYPE_TEXT_VARIATION_PASSWORD`, `TYPE_TEXT_VARIATION_WEB_PASSWORD`, `TYPE_NUMBER_VARIATION_PASSWORD`, etc.) or when using `PasswordTransformationMethod`.
182
+
183
+ ### Explicit Masking Components
184
+
185
+ To redact custom subtrees or images, use the built-in masking components:
186
+
187
+ ```tsx
188
+ import {
189
+ ClintplayMaskView,
190
+ ClintplayMaskTextInput,
191
+ ClintplayMaskImage,
192
+ } from 'clintplay-react-native';
193
+
194
+ // Redact an entire component subtree
195
+ <ClintplayMaskView>
196
+ <Text>Sensitive account balance: $1,000,000</Text>
197
+ </ClintplayMaskView>
198
+
199
+ // Drop-in masked input & image replacements
200
+ <ClintplayMaskTextInput value={ssn} onChangeText={setSsn} />
201
+ <ClintplayMaskImage source={{ uri: photoUrl }} style={styles.photo} />
202
+ ```
203
+
204
+ Under the hood, these components set `nativeID="clintplay-mask"`, instructing the native TurboModule to redact their geometry and contents.
205
+
206
+ ## Offline Retry Queue
207
+
208
+ Provide an `AsyncStorage` instance in `Clintplay.init({ asyncStorage })`. When the device loses network connectivity, failed batches are persisted to disk and automatically drained upon reconnection or next application boot. The queue features an LRU eviction policy with a 5 MiB storage cap.
209
+
210
+ ## Known Limitations & Sentry Integration
211
+
212
+ - **Console & Error Interception Order**: If using Sentry or other APM tools alongside `clintplay-react-native`, initialize `Clintplay.init()` after Sentry. Both SDKs maintain chain-safe handlers, but initialization order ensures all breadcrumbs and crash reports route cleanly.
213
+ - **Native View Hierarchy Testing**: Visual wireframe capture requires running on a real device or simulator with the TurboModule linked. When running in web environments or bare Node unit test runners, wireframe capture gracefully falls back to empty node sets.
214
+
215
+ ## Scripts
216
+
217
+ ```bash
218
+ pnpm test # vitest run
219
+ pnpm typecheck # tsc --noEmit
220
+ pnpm build # tsup → dist/{cjs,esm,dts}
221
+ ```
222
+
@@ -0,0 +1,50 @@
1
+ buildscript {
2
+ // Match the consumer app's Kotlin version so we don't drag in a
3
+ // conflicting toolchain. Most modern Expo SDK 54 / RN 0.81 apps
4
+ // ship Kotlin 1.9.x.
5
+ ext.kotlin_version = rootProject.ext.kotlin_version ?: '1.9.24'
6
+
7
+ repositories {
8
+ google()
9
+ mavenCentral()
10
+ }
11
+ dependencies {
12
+ classpath "com.android.tools.build:gradle:8.6.0"
13
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
14
+ }
15
+ }
16
+
17
+ apply plugin: 'com.android.library'
18
+ apply plugin: 'org.jetbrains.kotlin.android'
19
+
20
+ def getExtOrDefault(name, defaultValue) {
21
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : defaultValue
22
+ }
23
+
24
+ android {
25
+ namespace 'digital.clint.clintplay'
26
+ compileSdk getExtOrDefault('compileSdkVersion', 34)
27
+
28
+ defaultConfig {
29
+ minSdk getExtOrDefault('minSdkVersion', 24)
30
+ targetSdk getExtOrDefault('targetSdkVersion', 34)
31
+ }
32
+
33
+ compileOptions {
34
+ sourceCompatibility JavaVersion.VERSION_17
35
+ targetCompatibility JavaVersion.VERSION_17
36
+ }
37
+
38
+ kotlinOptions {
39
+ jvmTarget = '17'
40
+ }
41
+ }
42
+
43
+ repositories {
44
+ mavenCentral()
45
+ google()
46
+ }
47
+
48
+ dependencies {
49
+ implementation 'com.facebook.react:react-android'
50
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="digital.clint.clintplay"/>
@@ -0,0 +1,362 @@
1
+ package digital.clint.clintplay
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.graphics.drawable.ColorDrawable
6
+ import android.graphics.drawable.Drawable
7
+ import android.os.Build
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import android.text.InputType
11
+ import android.text.method.PasswordTransformationMethod
12
+ import android.view.View
13
+ import android.view.ViewGroup
14
+ import android.view.WindowManager
15
+ import android.widget.EditText
16
+ import android.widget.TextView
17
+ import com.facebook.react.bridge.ReactApplicationContext
18
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
19
+ import com.facebook.react.bridge.ReactMethod
20
+ import com.facebook.react.module.annotations.ReactModule
21
+ import com.facebook.react.turbomodule.core.interfaces.TurboModule
22
+ import org.json.JSONArray
23
+ import org.json.JSONObject
24
+ import java.lang.reflect.Method
25
+ import java.util.concurrent.ConcurrentHashMap
26
+ import java.util.concurrent.CountDownLatch
27
+ import java.util.concurrent.TimeUnit
28
+ import java.util.concurrent.atomic.AtomicBoolean
29
+ import java.util.concurrent.atomic.AtomicInteger
30
+ import java.util.concurrent.atomic.AtomicReference
31
+ import kotlin.math.round
32
+
33
+ // Android side of the Clintplay TurboModule.
34
+ //
35
+ // Walks the Android View hierarchy from the active Activity's decorView and
36
+ // serializes each View into an rrweb-shaped wireframe snapshot with
37
+ // coordinate conversion, background colors, extracted text, and
38
+ // privacy auto-masking (clintplay-mask tags, password fields, and sensitive autofill hints).
39
+ //
40
+ // React Native's codegen emits `NativeClintplayRNSpec` from
41
+ // `src/spec/NativeClintplayRN.ts` at build time. The TurboReactPackage
42
+ // in `ClintplayRNPackage.kt` routes calls here.
43
+ @ReactModule(name = ClintplayRNModule.NAME)
44
+ class ClintplayRNModule(reactContext: ReactApplicationContext) :
45
+ ReactContextBaseJavaModule(reactContext), TurboModule {
46
+
47
+ private val mainHandler = Handler(Looper.getMainLooper())
48
+
49
+ init {
50
+ try {
51
+ resolveNativeIdResId(reactContext)
52
+ resolveTestIdResId(reactContext)
53
+ } catch (_: Throwable) {}
54
+ }
55
+
56
+ override fun getName(): String = NAME
57
+
58
+ @ReactMethod(isBlockingSynchronousMethod = true)
59
+ fun getVersion(): String = VERSION
60
+
61
+ private fun formatColor(color: Int): String {
62
+ val alpha = (color ushr 24) and 0xFF
63
+ val red = (color ushr 16) and 0xFF
64
+ val green = (color ushr 8) and 0xFF
65
+ val blue = color and 0xFF
66
+ return String.format(java.util.Locale.US, "#%02x%02x%02x%02x", red, green, blue, alpha)
67
+ }
68
+
69
+ private fun extractBackgroundColor(drawable: Drawable?): String? {
70
+ if (drawable == null) return null
71
+ if (drawable is ColorDrawable) {
72
+ return formatColor(drawable.color)
73
+ }
74
+ val drawableClass = drawable.javaClass
75
+ val cached = getColorMethodCache.computeIfAbsent(drawableClass) { cls ->
76
+ try {
77
+ cls.getMethod("getColor")
78
+ } catch (_: Throwable) {
79
+ NO_METHOD_SENTINEL
80
+ }
81
+ }
82
+ if (cached is Method) {
83
+ try {
84
+ val colorObj = cached.invoke(drawable)
85
+ if (colorObj is Int) {
86
+ return formatColor(colorObj)
87
+ }
88
+ } catch (_: Throwable) {}
89
+ }
90
+ return null
91
+ }
92
+
93
+ private fun hasSensitiveAutofillHints(view: View): Boolean {
94
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
95
+ try {
96
+ val hints = view.autofillHints ?: return false
97
+ for (hint in hints) {
98
+ val lower = hint.lowercase(java.util.Locale.US)
99
+ if (lower.contains("password") ||
100
+ lower.contains("creditcard") ||
101
+ lower.contains("credit_card") ||
102
+ lower.contains("otp") ||
103
+ lower.contains("onetimecode") ||
104
+ lower.contains("one_time_code") ||
105
+ lower.contains("cvv") ||
106
+ lower.contains("cvc")) {
107
+ return true
108
+ }
109
+ }
110
+ } catch (_: Throwable) {}
111
+ }
112
+ return false
113
+ }
114
+
115
+ private fun serializeView(
116
+ view: View?,
117
+ nodeCounter: AtomicInteger,
118
+ density: Float,
119
+ depth: Int = 0,
120
+ ): JSONObject? {
121
+ if (depth > 64) {
122
+ return null
123
+ }
124
+
125
+ if (view == null || view.visibility != View.VISIBLE || view.alpha <= 0.001f) {
126
+ return null
127
+ }
128
+
129
+ val nodeId = nodeCounter.getAndIncrement()
130
+ val nodeObj = JSONObject()
131
+ nodeObj.put("id", nodeId)
132
+ nodeObj.put("type", view.javaClass.simpleName)
133
+ nodeObj.put("className", view.javaClass.name)
134
+
135
+ // Window-relative coordinates (CAND-12)
136
+ val location = IntArray(2)
137
+ view.getLocationInWindow(location)
138
+
139
+ val safeDensity = if (density > 0f) density else 1f
140
+ val rawX = location[0].toDouble() / safeDensity
141
+ val rawY = location[1].toDouble() / safeDensity
142
+ val rawW = view.width.toDouble() / safeDensity
143
+ val rawH = view.height.toDouble() / safeDensity
144
+
145
+ val x = if (rawX.isNaN() || rawX.isInfinite()) 0.0 else round(rawX * 100.0) / 100.0
146
+ val y = if (rawY.isNaN() || rawY.isInfinite()) 0.0 else round(rawY * 100.0) / 100.0
147
+ val w = if (rawW.isNaN() || rawW.isInfinite()) 0.0 else round(rawW * 100.0) / 100.0
148
+ val h = if (rawH.isNaN() || rawH.isInfinite()) 0.0 else round(rawH * 100.0) / 100.0
149
+
150
+ nodeObj.put("x", x)
151
+ nodeObj.put("y", y)
152
+ nodeObj.put("width", w)
153
+ nodeObj.put("height", h)
154
+ nodeObj.put("w", w)
155
+ nodeObj.put("h", h)
156
+
157
+ if (view.id != View.NO_ID && view.id != 0) {
158
+ nodeObj.put("tag", view.id)
159
+ }
160
+
161
+ val bgHex = extractBackgroundColor(view.background)
162
+ if (bgHex != null) {
163
+ nodeObj.put("backgroundColor", bgHex)
164
+ nodeObj.put("bg", bgHex)
165
+ }
166
+
167
+ val nativeIdRes = resolveNativeIdResId(view.context)
168
+ val nativeIdTag = if (nativeIdRes != 0) view.getTag(nativeIdRes)?.toString() else null
169
+ val testIdRes = resolveTestIdResId(view.context)
170
+ val testIdTag = if (testIdRes != 0) view.getTag(testIdRes)?.toString() else null
171
+
172
+ val tagStr = view.tag?.toString()
173
+ val contentDesc = view.contentDescription?.toString()
174
+
175
+ val identifier = tagStr ?: nativeIdTag ?: testIdTag
176
+ if (!identifier.isNullOrEmpty()) {
177
+ nodeObj.put("accessibilityIdentifier", identifier)
178
+ }
179
+
180
+ val isPassword = (view is EditText && run {
181
+ val inputType = view.inputType
182
+ val inputClass = inputType and InputType.TYPE_MASK_CLASS
183
+ val variation = inputType and InputType.TYPE_MASK_VARIATION
184
+ (inputClass == InputType.TYPE_CLASS_TEXT && (
185
+ variation == InputType.TYPE_TEXT_VARIATION_PASSWORD ||
186
+ variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||
187
+ variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
188
+ )) || (inputClass == InputType.TYPE_CLASS_NUMBER && (
189
+ variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD
190
+ ))
191
+ }) || (view is TextView && view.transformationMethod is PasswordTransformationMethod)
192
+
193
+ val isAutofillSensitive = hasSensitiveAutofillHints(view)
194
+
195
+ val isMasked = tagStr == "clintplay-mask" ||
196
+ contentDesc == "clintplay-mask" ||
197
+ nativeIdTag == "clintplay-mask" ||
198
+ testIdTag == "clintplay-mask" ||
199
+ isPassword ||
200
+ isAutofillSensitive
201
+
202
+ if (!contentDesc.isNullOrEmpty()) {
203
+ nodeObj.put("accessibilityLabel", if (isMasked) "***" else contentDesc)
204
+ }
205
+
206
+ var extractedText: String? = null
207
+ if (view is TextView) {
208
+ extractedText = view.text?.toString()
209
+ }
210
+
211
+ if (extractedText != null) {
212
+ nodeObj.put("text", if (isMasked) "***" else extractedText)
213
+ }
214
+
215
+ if (isMasked) {
216
+ nodeObj.put("masked", true)
217
+ nodeObj.put("children", JSONArray())
218
+ return nodeObj
219
+ }
220
+
221
+ nodeObj.put("masked", false)
222
+
223
+ val childrenArray = JSONArray()
224
+ if (view is ViewGroup) {
225
+ val childCount = view.childCount
226
+ for (i in 0 until childCount) {
227
+ val childView = view.getChildAt(i) ?: continue
228
+ val childJson = serializeView(childView, nodeCounter, density, depth + 1)
229
+ if (childJson != null) {
230
+ childrenArray.put(childJson)
231
+ }
232
+ }
233
+ }
234
+ nodeObj.put("children", childrenArray)
235
+
236
+ return nodeObj
237
+ }
238
+
239
+ private fun captureViewHierarchyOnMainThread(activity: Activity): String {
240
+ return try {
241
+ if (activity.isFinishing || activity.isDestroyed) {
242
+ return FALLBACK_JSON
243
+ }
244
+
245
+ val window = activity.window ?: return FALLBACK_JSON
246
+
247
+ // Check FLAG_SECURE (CAND-08)
248
+ if ((window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0) {
249
+ return FALLBACK_JSON
250
+ }
251
+
252
+ val decorView = try {
253
+ window.decorView
254
+ } catch (_: Throwable) {
255
+ return FALLBACK_JSON
256
+ } ?: return FALLBACK_JSON
257
+
258
+ val density = decorView.resources.displayMetrics.density
259
+ val nodeCounter = AtomicInteger(1)
260
+ val rootNode = serializeView(decorView, nodeCounter, density, 0)
261
+ ?: return FALLBACK_JSON
262
+
263
+ val ts = System.currentTimeMillis()
264
+ val payload = JSONObject()
265
+ payload.put("version", 1)
266
+ payload.put("ts", ts)
267
+ payload.put("timestamp", ts)
268
+
269
+ val nodes = JSONArray()
270
+ nodes.put(rootNode)
271
+ payload.put("nodes", nodes)
272
+
273
+ payload.toString()
274
+ } catch (_: Throwable) {
275
+ FALLBACK_JSON
276
+ }
277
+ }
278
+
279
+ @ReactMethod(isBlockingSynchronousMethod = true)
280
+ fun captureViewHierarchy(): String {
281
+ val activity = currentActivity ?: return FALLBACK_JSON
282
+
283
+ if (activity.isFinishing || activity.isDestroyed) {
284
+ return FALLBACK_JSON
285
+ }
286
+
287
+ if (Looper.myLooper() == Looper.getMainLooper()) {
288
+ return captureViewHierarchyOnMainThread(activity)
289
+ }
290
+
291
+ val isCancelled = AtomicBoolean(false)
292
+ val result = AtomicReference(FALLBACK_JSON)
293
+ val latch = CountDownLatch(1)
294
+
295
+ val callback = Runnable {
296
+ if (isCancelled.get()) {
297
+ return@Runnable
298
+ }
299
+ try {
300
+ result.set(captureViewHierarchyOnMainThread(activity))
301
+ } catch (_: Throwable) {
302
+ result.set(FALLBACK_JSON)
303
+ } finally {
304
+ latch.countDown()
305
+ }
306
+ }
307
+
308
+ val posted = mainHandler.post(callback)
309
+ if (!posted) {
310
+ return FALLBACK_JSON
311
+ }
312
+
313
+ val completed = try {
314
+ latch.await(250, TimeUnit.MILLISECONDS)
315
+ } catch (_: InterruptedException) {
316
+ Thread.currentThread().interrupt()
317
+ false
318
+ }
319
+
320
+ if (!completed) {
321
+ isCancelled.set(true)
322
+ mainHandler.removeCallbacks(callback)
323
+ return FALLBACK_JSON
324
+ }
325
+
326
+ return result.get()
327
+ }
328
+
329
+ companion object {
330
+ const val NAME = "ClintplayRN"
331
+ private const val VERSION = "0.1.0"
332
+ private const val FALLBACK_JSON = "{\"version\":1,\"nodes\":[]}"
333
+
334
+ private val NO_METHOD_SENTINEL = Any()
335
+ private val getColorMethodCache = ConcurrentHashMap<Class<*>, Any>()
336
+
337
+ @Volatile
338
+ private var cachedNativeIdResId: Int? = null
339
+ @Volatile
340
+ private var cachedTestIdResId: Int? = null
341
+
342
+ private fun resolveNativeIdResId(context: Context): Int {
343
+ cachedNativeIdResId?.let { return it }
344
+ var id = context.resources.getIdentifier("view_tag_native_id", "id", context.packageName)
345
+ if (id == 0) {
346
+ id = context.resources.getIdentifier("view_tag_native_id", "id", "com.facebook.react")
347
+ }
348
+ cachedNativeIdResId = id
349
+ return id
350
+ }
351
+
352
+ private fun resolveTestIdResId(context: Context): Int {
353
+ cachedTestIdResId?.let { return it }
354
+ var id = context.resources.getIdentifier("react_test_id", "id", context.packageName)
355
+ if (id == 0) {
356
+ id = context.resources.getIdentifier("react_test_id", "id", "com.facebook.react")
357
+ }
358
+ cachedTestIdResId = id
359
+ return id
360
+ }
361
+ }
362
+ }
@@ -0,0 +1,49 @@
1
+ package digital.clint.clintplay
2
+
3
+ import com.facebook.react.TurboReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+ import com.facebook.react.uimanager.ViewManager
9
+
10
+ // Registers the Clintplay TurboModule with the consumer's React
11
+ // Native runtime. Supports both New Architecture (TurboModule) and
12
+ // Old Architecture (Paper / Bridge) seamlessly.
13
+ class ClintplayRNPackage : TurboReactPackage() {
14
+
15
+ override fun getModule(
16
+ name: String,
17
+ reactContext: ReactApplicationContext,
18
+ ): NativeModule? {
19
+ return if (name == ClintplayRNModule.NAME) {
20
+ ClintplayRNModule(reactContext)
21
+ } else {
22
+ null
23
+ }
24
+ }
25
+
26
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
27
+ return ReactModuleInfoProvider {
28
+ mapOf(
29
+ ClintplayRNModule.NAME to ReactModuleInfo(
30
+ ClintplayRNModule.NAME,
31
+ ClintplayRNModule::class.java.name,
32
+ false, // canOverrideExistingModule
33
+ false, // needsEagerInit
34
+ false, // hasConstants
35
+ false, // isCxxModule
36
+ true // isTurboModule
37
+ )
38
+ )
39
+ }
40
+ }
41
+
42
+ override fun createNativeModules(
43
+ reactContext: ReactApplicationContext,
44
+ ): List<NativeModule> = listOf(ClintplayRNModule(reactContext))
45
+
46
+ override fun createViewManagers(
47
+ reactContext: ReactApplicationContext,
48
+ ): List<ViewManager<*, *>> = emptyList()
49
+ }