@goliapkg/sentori-react-native 0.9.11 → 1.0.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/android/src/main/java/com/sentori/SentoriForegroundActivity.kt +145 -0
  2. package/android/src/main/java/com/sentori/SentoriModule.kt +13 -0
  3. package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +261 -68
  4. package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
  5. package/ios/SentoriModule.swift +15 -0
  6. package/ios/SentoriReplayCapture.swift +135 -10
  7. package/ios/SentoriScreenshotCapture.swift +69 -3
  8. package/lib/base64.d.ts +25 -0
  9. package/lib/base64.d.ts.map +1 -0
  10. package/lib/base64.js +30 -0
  11. package/lib/base64.js.map +1 -0
  12. package/lib/capture.d.ts +20 -1
  13. package/lib/capture.d.ts.map +1 -1
  14. package/lib/capture.js +45 -21
  15. package/lib/capture.js.map +1 -1
  16. package/lib/index.d.ts +2 -1
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +2 -1
  19. package/lib/index.js.bak +64 -0
  20. package/lib/index.js.map +1 -1
  21. package/lib/native.d.ts +68 -0
  22. package/lib/native.d.ts.map +1 -1
  23. package/lib/native.js +115 -0
  24. package/lib/native.js.map +1 -1
  25. package/lib/replay.d.ts +28 -4
  26. package/lib/replay.d.ts.map +1 -1
  27. package/lib/replay.js +242 -65
  28. package/lib/replay.js.map +1 -1
  29. package/lib/transport.d.ts.map +1 -1
  30. package/lib/transport.js +16 -0
  31. package/lib/transport.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/__tests__/base64.test.ts +55 -0
  34. package/src/__tests__/capture-replay.test.ts +150 -0
  35. package/src/__tests__/replay-encoding.test.ts +237 -0
  36. package/src/base64.ts +29 -0
  37. package/src/capture.ts +56 -22
  38. package/src/index.ts +3 -0
  39. package/src/native.ts +177 -0
  40. package/src/replay.ts +294 -70
  41. package/src/transport.ts +31 -0
@@ -0,0 +1,145 @@
1
+ package com.sentori
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.os.Bundle
6
+ import java.lang.ref.WeakReference
7
+
8
+ /**
9
+ * v1.0.0-rc.2 — process-wide foreground-Activity tracker.
10
+ *
11
+ * Both [SentoriScreenshotCapture] and [SentoriReplayCapture] need a
12
+ * pointer to the currently-foregrounded Activity to drive their
13
+ * native captures. The previous implementation registered each
14
+ * helper's own `ActivityLifecycleCallbacks` inside
15
+ * [SentoriCrashHandler.register], which runs from the Expo module's
16
+ * `OnCreate` lifecycle — **after** the MainActivity has already been
17
+ * resumed in the typical Insight / Expo dev-launcher topology.
18
+ * `ActivityLifecycleCallbacks` does not back-fill the current state,
19
+ * it only forwards future events, so `lastActivity` would stay null
20
+ * for the entire session if the user never backgrounded the app.
21
+ *
22
+ * Fix mirrors the iOS keyWindow 4-layer fallback:
23
+ *
24
+ * 1. Lifecycle callbacks track resumed/started activities going
25
+ * forward (the same approach as before, kept).
26
+ * 2. **On first install** we also probe `ActivityThread` reflection
27
+ * to back-fill whatever Activity is currently foreground, so the
28
+ * already-resumed window is seen by the SDK from boot.
29
+ * 3. If reflection fails (e.g. on a future Android release that
30
+ * removes `ActivityThread` access), the lifecycle callbacks
31
+ * still catch the next foreground transition.
32
+ *
33
+ * `lastPath` carries the diagnostic provenance for `probeWireframe` /
34
+ * `probeScreenshot`, so the JS side (and Insight) can tell whether
35
+ * the SDK was working off a live lifecycle event or fell back to
36
+ * reflection.
37
+ */
38
+ object SentoriForegroundActivity {
39
+
40
+ @Volatile private var lastActivity: WeakReference<Activity>? = null
41
+ @Volatile var lastPath: String = "none(not-yet-resolved)"
42
+ private set
43
+
44
+ /** Idempotent. Call from [SentoriCrashHandler.register]; subsequent
45
+ * calls are no-ops because the same callbacks object would be
46
+ * registered twice otherwise. */
47
+ @Volatile private var registered = false
48
+
49
+ @Synchronized
50
+ fun install(application: Application) {
51
+ if (registered) {
52
+ // Even on second install attempt, try the reflection
53
+ // back-fill again — process state may have advanced.
54
+ backfillFromActivityThread()
55
+ return
56
+ }
57
+ registered = true
58
+ application.registerActivityLifecycleCallbacks(object :
59
+ Application.ActivityLifecycleCallbacks {
60
+ override fun onActivityCreated(a: Activity, b: Bundle?) {
61
+ set(a, "lifecycle.created")
62
+ }
63
+ override fun onActivityStarted(a: Activity) {
64
+ set(a, "lifecycle.started")
65
+ }
66
+ override fun onActivityResumed(a: Activity) {
67
+ set(a, "lifecycle.resumed")
68
+ }
69
+ override fun onActivityPaused(a: Activity) {}
70
+ override fun onActivityStopped(a: Activity) {}
71
+ override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
72
+ override fun onActivityDestroyed(a: Activity) {}
73
+ })
74
+ // Back-fill in case the Activity was already resumed before
75
+ // we got installed (the dev-launcher → MainActivity transition
76
+ // happens before the Expo module's OnCreate fires).
77
+ backfillFromActivityThread()
78
+ }
79
+
80
+ /** Public for tests + the helpers' own explicit `setActivity`
81
+ * paths (kept for backwards-compat). */
82
+ fun set(activity: Activity, source: String) {
83
+ lastActivity = WeakReference(activity)
84
+ lastPath = source
85
+ }
86
+
87
+ fun current(): Activity? {
88
+ val live = lastActivity?.get()
89
+ if (live != null) return live
90
+ // Last-ditch: try reflection again in case install() ran before
91
+ // any Activity existed. Cheap if it fails.
92
+ backfillFromActivityThread()
93
+ return lastActivity?.get()
94
+ }
95
+
96
+ /**
97
+ * Best-effort foreground-Activity lookup using non-SDK reflection.
98
+ * Walks `ActivityThread.sCurrentActivityThread.mActivities` (an
99
+ * `ArrayMap<IBinder, ActivityClientRecord>`) and finds the record
100
+ * whose `paused` field is false. This is what Stetho / LeakCanary
101
+ * / Firebase Performance do, with the same caveat that future
102
+ * Android versions may break it — failures are silent and the
103
+ * lifecycle-callbacks path still wins.
104
+ *
105
+ * Tagged via `lastPath = "reflection.activityThread"` so the JS
106
+ * probe can see when reflection had to step in.
107
+ */
108
+ @Suppress("PrivateApi", "UNCHECKED_CAST")
109
+ private fun backfillFromActivityThread() {
110
+ try {
111
+ val activityThreadClass = Class.forName("android.app.ActivityThread")
112
+ val currentActivityThread = activityThreadClass
113
+ .getDeclaredMethod("currentActivityThread")
114
+ .also { it.isAccessible = true }
115
+ .invoke(null) ?: return
116
+ val activitiesField = activityThreadClass
117
+ .getDeclaredField("mActivities")
118
+ .also { it.isAccessible = true }
119
+ val activities = activitiesField.get(currentActivityThread) ?: return
120
+
121
+ // ArrayMap<IBinder, ActivityClientRecord> — iterate via reflection
122
+ // so we don't have to depend on the (also private) ArrayMap shape.
123
+ val valuesIter = (activities as? Map<*, *>)?.values?.iterator() ?: return
124
+ while (valuesIter.hasNext()) {
125
+ val record = valuesIter.next() ?: continue
126
+ val recordClass = record.javaClass
127
+ val pausedField = try {
128
+ recordClass.getDeclaredField("paused").also { it.isAccessible = true }
129
+ } catch (_: NoSuchFieldException) { null }
130
+ val activityField = try {
131
+ recordClass.getDeclaredField("activity").also { it.isAccessible = true }
132
+ } catch (_: NoSuchFieldException) { null } ?: continue
133
+ val paused = pausedField?.getBoolean(record) ?: false
134
+ val candidate = activityField.get(record) as? Activity ?: continue
135
+ if (!paused && !candidate.isFinishing && !candidate.isDestroyed) {
136
+ set(candidate, "reflection.activityThread")
137
+ return
138
+ }
139
+ }
140
+ } catch (_: Throwable) {
141
+ // Reflection unavailable; the lifecycle-callbacks path
142
+ // will still catch the next foreground transition.
143
+ }
144
+ }
145
+ }
@@ -34,6 +34,19 @@ class SentoriModule : Module() {
34
34
  SentoriReplayCapture.captureWireframe(maskedIds)
35
35
  }
36
36
 
37
+ // v0.9.12 — diagnostic readout for replay. See iOS side.
38
+ Function("probeWireframe") {
39
+ SentoriReplayCapture.probe()
40
+ }
41
+
42
+ // v1.0.0-rc.2 — diagnostic readout for screenshot. Surfaces
43
+ // foreground-Activity provenance, decor-view state, and the
44
+ // last failure reason so the JS side can ship raw state back
45
+ // when screenshot returns null.
46
+ Function("probeScreenshot") {
47
+ SentoriScreenshotCapture.probe()
48
+ }
49
+
37
50
  // v0.9.4 #1 — Mobile Vitals exposure.
38
51
  Function("markJsBridgeReady") {
39
52
  SentoriMobileVitals.markJsBridgeReady()
@@ -1,7 +1,12 @@
1
1
  package com.sentori
2
2
 
3
3
  import android.app.Activity
4
- import android.graphics.Rect
4
+ import android.graphics.drawable.BitmapDrawable
5
+ import android.graphics.drawable.ColorDrawable
6
+ import android.graphics.drawable.Drawable
7
+ import android.graphics.drawable.GradientDrawable
8
+ import android.graphics.drawable.LayerDrawable
9
+ import android.graphics.drawable.StateListDrawable
5
10
  import android.view.View
6
11
  import android.view.ViewGroup
7
12
  import android.widget.EditText
@@ -9,7 +14,6 @@ import android.widget.ImageView
9
14
  import android.widget.TextView
10
15
  import org.json.JSONArray
11
16
  import org.json.JSONObject
12
- import java.lang.ref.WeakReference
13
17
 
14
18
  /**
15
19
  * v0.9.6 #2 — wireframe session replay (Android side).
@@ -26,41 +30,97 @@ import java.lang.ref.WeakReference
26
30
  object SentoriReplayCapture {
27
31
 
28
32
  private const val MAX_NODES = 800
33
+ private const val MAX_DEPTH = 60
29
34
 
30
- @Volatile private var lastActivity: WeakReference<Activity>? = null
35
+ // Diagnostic readouts. Mirrors the iOS side. Surfaced via
36
+ // `probe()` so JS can answer "why is the ring shallow?" without
37
+ // parsing logcat.
38
+ //
39
+ // v0.9.12: lastPath + lastNodes
40
+ // v1.0.0-rc.3:
41
+ // * lastDepthMax — deepest descendant the walker reached. If
42
+ // this stays at 2 or 3 we know the recursion bailed early
43
+ // (the rc.2 zero-size-bails-subtree bug).
44
+ // * lastSizeBytes — byte length of the serialised payload. ~50
45
+ // bytes per node is typical; a 1 KB result with 800 nodes
46
+ // would be a red flag.
47
+ // * totalTicks / lastEmptyResultTicks — lifetime counters for
48
+ // ring health, so a thin-but-non-null capture doesn't slip
49
+ // through unnoticed.
50
+ @Volatile private var lastDiagPath: String = "none(not-yet-called)"
51
+ @Volatile private var lastDiagNodes: Int = 0
52
+ @Volatile private var lastDiagDepthMax: Int = 0
53
+ @Volatile private var lastDiagSizeBytes: Int = 0
54
+ @Volatile private var totalTicks: Long = 0
55
+ @Volatile private var totalEmptyResultTicks: Long = 0
31
56
 
57
+ @JvmStatic
58
+ fun probe(): Map<String, Any> {
59
+ val activity = SentoriForegroundActivity.current()
60
+ return mapOf(
61
+ "lastPath" to lastDiagPath,
62
+ "lastNodes" to lastDiagNodes,
63
+ "lastDepthMax" to lastDiagDepthMax,
64
+ "lastSizeBytes" to lastDiagSizeBytes,
65
+ "totalTicks" to totalTicks,
66
+ "totalEmptyResultTicks" to totalEmptyResultTicks,
67
+ "trackedSource" to SentoriForegroundActivity.lastPath,
68
+ "trackedActivity" to (activity?.javaClass?.name ?: "null"),
69
+ "decorViewFound" to (activity?.window?.decorView != null),
70
+ )
71
+ }
72
+
73
+ /** Backwards compat — pre-rc.2 callers that hand-fed an Activity
74
+ * through `setActivity` still work; we forward to the shared
75
+ * tracker so screenshot + replay both see it. */
32
76
  @JvmStatic
33
77
  fun setActivity(activity: Activity?) {
34
- lastActivity = activity?.let { WeakReference(it) }
78
+ if (activity != null) SentoriForegroundActivity.set(activity, "manual.setActivity")
35
79
  }
36
80
 
37
- /** Attach an ActivityLifecycleCallbacks so future
38
- * `captureWireframe()` calls know which Activity to walk. */
81
+ /** Idempotent. Wires the replay helper into the shared tracker;
82
+ * kept as a public entrypoint for backwards compat with existing
83
+ * call sites. */
39
84
  @JvmStatic
40
85
  fun register(application: android.app.Application) {
41
- application.registerActivityLifecycleCallbacks(object :
42
- android.app.Application.ActivityLifecycleCallbacks {
43
- override fun onActivityCreated(a: Activity, b: android.os.Bundle?) { setActivity(a) }
44
- override fun onActivityStarted(a: Activity) { setActivity(a) }
45
- override fun onActivityResumed(a: Activity) { setActivity(a) }
46
- override fun onActivityPaused(a: Activity) {}
47
- override fun onActivityStopped(a: Activity) {}
48
- override fun onActivitySaveInstanceState(a: Activity, b: android.os.Bundle) {}
49
- override fun onActivityDestroyed(a: Activity) {}
50
- })
86
+ SentoriForegroundActivity.install(application)
51
87
  }
52
88
 
53
89
  @JvmStatic
54
90
  fun captureWireframe(maskedIds: List<String>): String? {
55
- val activity = lastActivity?.get() ?: return null
56
- val root = activity.window?.decorView ?: return null
57
- if (root.width <= 0 || root.height <= 0) return null
91
+ totalTicks++
92
+ val activity = SentoriForegroundActivity.current()
93
+ if (activity == null) {
94
+ lastDiagPath = "activity.null"
95
+ totalEmptyResultTicks++
96
+ return null
97
+ }
98
+ // rc.8 — anchor the walk at android.R.id.content, NOT
99
+ // window.decorView. decorView includes the StatusBarBackground
100
+ // and NavigationBarBackground sibling views the PhoneWindow
101
+ // injects (full display width, positioned in absolute window
102
+ // coords). Insight 2026-05-18 saw those bleed into the
103
+ // wireframe as horizontal grey bars stretching beyond the
104
+ // viewport width. Anchoring at the content FrameLayout drops
105
+ // them while keeping the app's React tree intact.
106
+ val decor = activity.window?.decorView
107
+ val root = decor?.findViewById<View>(android.R.id.content)
108
+ if (root == null) {
109
+ lastDiagPath = if (decor == null) "decorView.null" else "contentView.null"
110
+ totalEmptyResultTicks++
111
+ return null
112
+ }
113
+ if (root.width <= 0 || root.height <= 0) {
114
+ lastDiagPath = "root.zero-size"
115
+ totalEmptyResultTicks++
116
+ return null
117
+ }
58
118
 
59
119
  val maskedSet = maskedIds.toHashSet()
60
120
  val nodes = JSONArray()
61
- val rect = Rect()
62
121
  val rootLoc = IntArray(2).also { root.getLocationInWindow(it) }
63
- walk(root, false, maskedSet, rootLoc, rect, nodes)
122
+ val ctx = WalkContext(rootLoc = rootLoc, maskedSet = maskedSet)
123
+ walk(root, depth = 0, parentMasked = false, ctx = ctx, nodes = nodes)
64
124
 
65
125
  val payload = JSONObject().apply {
66
126
  put("ts", System.currentTimeMillis())
@@ -68,77 +128,136 @@ object SentoriReplayCapture {
68
128
  put("height", root.height)
69
129
  put("nodes", nodes)
70
130
  }
71
- return payload.toString()
131
+ val serialised = payload.toString()
132
+
133
+ lastDiagPath = "ok(${SentoriForegroundActivity.lastPath})"
134
+ lastDiagNodes = nodes.length()
135
+ lastDiagDepthMax = ctx.depthMax
136
+ lastDiagSizeBytes = serialised.length
137
+
138
+ if (nodes.length() == 0) totalEmptyResultTicks++
139
+
140
+ return serialised
72
141
  }
73
142
 
143
+ /** Per-walk scratch: tracks the deepest descendant reached so
144
+ * the probe can surface whether the recursion ran or bailed.
145
+ * Bundled into one object to keep the recursive signature
146
+ * manageable. */
147
+ private class WalkContext(
148
+ val rootLoc: IntArray,
149
+ val maskedSet: Set<String>,
150
+ var depthMax: Int = 0,
151
+ )
152
+
153
+ /**
154
+ * Recursive walker.
155
+ *
156
+ * v1.0.0-rc.3 fix: previously this function returned ENTIRELY
157
+ * when the view itself had `width <= 0 || height <= 0`. That
158
+ * meant any ViewGroup wrapper that happened to measure to zero
159
+ * size during the tick (common on Fabric / RN's intermediate
160
+ * shadow-tree wrappers, and on lazy-layout phases) skipped the
161
+ * whole descendant subtree — Insight 2026-05-17 verify event
162
+ * saw 800-node frames whose subtree was actually thousands of
163
+ * Views deep but only the root + 2-3 wrappers made it into the
164
+ * JSON.
165
+ *
166
+ * Now we separate "emit a node for this view" from "recurse into
167
+ * its children". A zero-size view doesn't get an emitted node
168
+ * (no visual contribution) but its descendants still get walked
169
+ * — they may have real frames.
170
+ */
74
171
  private fun walk(
75
172
  view: View,
173
+ depth: Int,
76
174
  parentMasked: Boolean,
77
- maskedSet: Set<String>,
78
- rootLoc: IntArray,
79
- scratch: Rect,
175
+ ctx: WalkContext,
80
176
  nodes: JSONArray,
81
177
  ) {
82
178
  if (nodes.length() >= MAX_NODES) return
179
+ if (depth >= MAX_DEPTH) return
83
180
  if (view.visibility != View.VISIBLE || view.alpha < 0.01) return
84
181
 
182
+ if (depth > ctx.depthMax) ctx.depthMax = depth
183
+
85
184
  val viewTag = view.tag as? String
86
- val isThisMasked = viewTag != null && maskedSet.contains(viewTag)
185
+ val isThisMasked = viewTag != null && ctx.maskedSet.contains(viewTag)
87
186
  val masked = parentMasked || isThisMasked
88
187
 
89
- val loc = IntArray(2)
90
- view.getLocationInWindow(loc)
91
- val x = loc[0] - rootLoc[0]
92
- val y = loc[1] - rootLoc[1]
93
188
  val w = view.width
94
189
  val h = view.height
95
- if (w <= 0 || h <= 0) return
96
190
 
97
- val node = JSONObject().apply {
98
- put("x", x)
99
- put("y", y)
100
- put("w", w)
101
- put("h", h)
102
- }
191
+ // Emit a node ONLY when the view has visual extent. A zero-
192
+ // size view contributes nothing to render but its subtree
193
+ // might; recurse below regardless.
194
+ if (w > 0 && h > 0) {
195
+ val loc = IntArray(2)
196
+ view.getLocationInWindow(loc)
197
+ val x = loc[0] - ctx.rootLoc[0]
198
+ val y = loc[1] - ctx.rootLoc[1]
103
199
 
104
- var kindEmitted = false
105
- when {
106
- masked -> {
107
- node.put("kind", "mask")
108
- kindEmitted = true
109
- }
110
- view is TextView && !view.text.isNullOrEmpty() -> {
111
- node.put("kind", "text")
112
- val text = view.text.toString().let { if (it.length > 200) it.substring(0, 200) else it }
113
- node.put("text", text)
114
- node.put("color", colorToHex(view.currentTextColor))
115
- kindEmitted = true
116
- }
117
- view is EditText -> {
118
- node.put("kind", "text")
119
- val text = (view.text ?: "").toString().let { if (it.length > 200) it.substring(0, 200) else it }
120
- node.put("text", text)
121
- kindEmitted = true
200
+ val node = JSONObject().apply {
201
+ put("x", x)
202
+ put("y", y)
203
+ put("w", w)
204
+ put("h", h)
122
205
  }
123
- view is ImageView -> {
124
- node.put("kind", "image")
125
- kindEmitted = true
126
- }
127
- view.background != null -> {
128
- node.put("kind", "rect")
129
- // Background drawables don't always expose color directly.
130
- // Skip color for non-ColorDrawable; renderer falls back to neutral.
131
- kindEmitted = true
206
+
207
+ var kindEmitted = false
208
+ when {
209
+ masked -> {
210
+ node.put("kind", "mask")
211
+ kindEmitted = true
212
+ }
213
+ view is TextView && !view.text.isNullOrEmpty() -> {
214
+ node.put("kind", "text")
215
+ val text = view.text.toString().let { if (it.length > 200) it.substring(0, 200) else it }
216
+ node.put("text", text)
217
+ node.put("color", colorToHex(view.currentTextColor))
218
+ kindEmitted = true
219
+ }
220
+ view is EditText -> {
221
+ node.put("kind", "text")
222
+ val text = (view.text ?: "").toString().let { if (it.length > 200) it.substring(0, 200) else it }
223
+ node.put("text", text)
224
+ kindEmitted = true
225
+ }
226
+ view is ImageView -> {
227
+ node.put("kind", "image")
228
+ kindEmitted = true
229
+ }
230
+ view.background != null -> {
231
+ // rc.8 — backgrounds backed by a BitmapDrawable
232
+ // (a View whose backgroundImage / drawable
233
+ // resource is a raster) emit as `image` kind so
234
+ // the dashboard renders them as a media region,
235
+ // not as a grey rect. Everything else stays
236
+ // `rect` and tries to extract a fill colour.
237
+ val bg = view.background
238
+ if (bg is BitmapDrawable) {
239
+ node.put("kind", "image")
240
+ } else {
241
+ node.put("kind", "rect")
242
+ val color = extractDrawableColor(bg)
243
+ if (color != null && (color shr 24 and 0xff) != 0) {
244
+ node.put("color", colorToHex(color))
245
+ }
246
+ }
247
+ kindEmitted = true
248
+ }
132
249
  }
133
- }
134
250
 
135
- if (kindEmitted) {
136
- nodes.put(node)
251
+ if (kindEmitted) {
252
+ nodes.put(node)
253
+ }
137
254
  }
138
255
 
256
+ // Always recurse — even zero-size wrappers can host real
257
+ // descendants (the rc.3 fix).
139
258
  if (!masked && view is ViewGroup) {
140
259
  for (i in 0 until view.childCount) {
141
- walk(view.getChildAt(i), masked, maskedSet, rootLoc, scratch, nodes)
260
+ walk(view.getChildAt(i), depth + 1, masked, ctx, nodes)
142
261
  }
143
262
  }
144
263
  }
@@ -150,4 +269,78 @@ object SentoriReplayCapture {
150
269
  val b = c and 0xff
151
270
  return String.format("#%02X%02X%02X%02X", r, g, b, a)
152
271
  }
272
+
273
+ /** rc.5 / rc.7 — best-effort fill-colour extraction for the View's
274
+ * background Drawable. We hit:
275
+ *
276
+ * - `ColorDrawable` — flat `<View style={{ backgroundColor }}>`.
277
+ * - `GradientDrawable` — RN ≤ 0.73's path for backgroundColor +
278
+ * borderRadius / borderWidth.
279
+ * - Any `LayerDrawable` subclass — RippleDrawable (Pressable),
280
+ * `com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable`
281
+ * (RN 0.74+ Fabric path, wraps the real
282
+ * `BackgroundDrawable` layer alongside borders / shadows), and
283
+ * any other future composite. Iterate layers, recurse.
284
+ * - **rc.7 reflective fallback** — for the BackgroundDrawable
285
+ * itself (RN 0.74+, Kotlin `internal class` so we can't
286
+ * import it from this module) and any other custom Drawable
287
+ * that follows the convention of exposing `getBackgroundColor()`
288
+ * or `getColor()`. Without this, Insight's app — which uses
289
+ * Pressables and rounded Views and so renders nearly every
290
+ * coloured surface via CompositeBackgroundDrawable —
291
+ * surfaced zero `node.color` fields on rc.5. */
292
+ private fun extractDrawableColor(drawable: Drawable?): Int? {
293
+ return when (drawable) {
294
+ null -> null
295
+ is ColorDrawable -> drawable.color
296
+ is GradientDrawable -> {
297
+ val csl = drawable.color
298
+ csl?.defaultColor
299
+ }
300
+ is StateListDrawable -> {
301
+ // rc.8 — Pressable / TouchableOpacity wrap their child
302
+ // in a StateListDrawable (default state + pressed
303
+ // state). `.current` returns the currently-applied
304
+ // state's drawable, which during a normal capture
305
+ // is the unpressed visual — exactly what we want.
306
+ // AnimatedStateListDrawable extends StateListDrawable
307
+ // so it inherits this branch.
308
+ extractDrawableColor(drawable.current)
309
+ }
310
+ is BitmapDrawable -> null
311
+ is LayerDrawable -> {
312
+ for (i in 0 until drawable.numberOfLayers) {
313
+ val inner = drawable.getDrawable(i)
314
+ val c = extractDrawableColor(inner)
315
+ if (c != null && (c shr 24 and 0xff) != 0) return c
316
+ }
317
+ null
318
+ }
319
+ else -> extractByReflection(drawable)
320
+ }
321
+ }
322
+
323
+ /** rc.7 — read `getBackgroundColor()` / `getColor()` via Java
324
+ * reflection. Kotlin synthesizes the former from any `var
325
+ * backgroundColor: Int` declaration, so RN's internal
326
+ * `BackgroundDrawable` (which holds the actual paint colour
327
+ * behind any RN View with backgroundColor) exposes it
328
+ * automatically. Any throw / non-Int return falls through to
329
+ * null — fully best-effort. */
330
+ private fun extractByReflection(drawable: Drawable): Int? {
331
+ val cls = drawable.javaClass
332
+ for (name in arrayOf("getBackgroundColor", "getColor")) {
333
+ try {
334
+ val method = cls.getMethod(name)
335
+ val result = method.invoke(drawable)
336
+ if (result is Int) return result
337
+ } catch (_: NoSuchMethodException) {
338
+ // try next
339
+ } catch (_: Throwable) {
340
+ // some custom drawables throw on reflective access; bail
341
+ return null
342
+ }
343
+ }
344
+ return null
345
+ }
153
346
  }