@goliapkg/sentori-react-native 1.0.0-rc.1 → 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.
- package/android/src/main/java/com/sentori/SentoriForegroundActivity.kt +145 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +8 -0
- package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +240 -77
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
- package/ios/SentoriModule.swift +7 -0
- package/ios/SentoriReplayCapture.swift +38 -7
- package/ios/SentoriScreenshotCapture.swift +69 -3
- package/lib/base64.d.ts +25 -0
- package/lib/base64.d.ts.map +1 -0
- package/lib/base64.js +30 -0
- package/lib/base64.js.map +1 -0
- package/lib/capture.d.ts +20 -1
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +45 -21
- package/lib/capture.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/native.d.ts +40 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +59 -0
- package/lib/native.js.map +1 -1
- package/lib/replay.d.ts +28 -4
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +218 -108
- package/lib/replay.js.map +1 -1
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +16 -0
- package/lib/transport.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/base64.test.ts +55 -0
- package/src/__tests__/capture-replay.test.ts +150 -0
- package/src/__tests__/replay-encoding.test.ts +237 -0
- package/src/base64.ts +29 -0
- package/src/capture.ts +56 -22
- package/src/index.ts +1 -0
- package/src/native.ts +102 -2
- package/src/replay.ts +271 -115
- 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
|
+
}
|
|
@@ -39,6 +39,14 @@ class SentoriModule : Module() {
|
|
|
39
39
|
SentoriReplayCapture.probe()
|
|
40
40
|
}
|
|
41
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
|
+
|
|
42
50
|
// v0.9.4 #1 — Mobile Vitals exposure.
|
|
43
51
|
Function("markJsBridgeReady") {
|
|
44
52
|
SentoriMobileVitals.markJsBridgeReady()
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
package com.sentori
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
-
import android.graphics.
|
|
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,71 +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
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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.
|
|
34
50
|
@Volatile private var lastDiagPath: String = "none(not-yet-called)"
|
|
35
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
|
|
36
56
|
|
|
37
57
|
@JvmStatic
|
|
38
58
|
fun probe(): Map<String, Any> {
|
|
39
|
-
val activity =
|
|
59
|
+
val activity = SentoriForegroundActivity.current()
|
|
40
60
|
return mapOf(
|
|
41
61
|
"lastPath" to lastDiagPath,
|
|
42
62
|
"lastNodes" to lastDiagNodes,
|
|
43
|
-
"
|
|
44
|
-
"
|
|
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),
|
|
45
70
|
)
|
|
46
71
|
}
|
|
47
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. */
|
|
48
76
|
@JvmStatic
|
|
49
77
|
fun setActivity(activity: Activity?) {
|
|
50
|
-
|
|
78
|
+
if (activity != null) SentoriForegroundActivity.set(activity, "manual.setActivity")
|
|
51
79
|
}
|
|
52
80
|
|
|
53
|
-
/**
|
|
54
|
-
*
|
|
81
|
+
/** Idempotent. Wires the replay helper into the shared tracker;
|
|
82
|
+
* kept as a public entrypoint for backwards compat with existing
|
|
83
|
+
* call sites. */
|
|
55
84
|
@JvmStatic
|
|
56
85
|
fun register(application: android.app.Application) {
|
|
57
|
-
|
|
58
|
-
android.app.Application.ActivityLifecycleCallbacks {
|
|
59
|
-
override fun onActivityCreated(a: Activity, b: android.os.Bundle?) { setActivity(a) }
|
|
60
|
-
override fun onActivityStarted(a: Activity) { setActivity(a) }
|
|
61
|
-
override fun onActivityResumed(a: Activity) { setActivity(a) }
|
|
62
|
-
override fun onActivityPaused(a: Activity) {}
|
|
63
|
-
override fun onActivityStopped(a: Activity) {}
|
|
64
|
-
override fun onActivitySaveInstanceState(a: Activity, b: android.os.Bundle) {}
|
|
65
|
-
override fun onActivityDestroyed(a: Activity) {}
|
|
66
|
-
})
|
|
86
|
+
SentoriForegroundActivity.install(application)
|
|
67
87
|
}
|
|
68
88
|
|
|
69
89
|
@JvmStatic
|
|
70
90
|
fun captureWireframe(maskedIds: List<String>): String? {
|
|
71
|
-
|
|
91
|
+
totalTicks++
|
|
92
|
+
val activity = SentoriForegroundActivity.current()
|
|
72
93
|
if (activity == null) {
|
|
73
94
|
lastDiagPath = "activity.null"
|
|
95
|
+
totalEmptyResultTicks++
|
|
74
96
|
return null
|
|
75
97
|
}
|
|
76
|
-
|
|
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)
|
|
77
108
|
if (root == null) {
|
|
78
|
-
lastDiagPath = "decorView.null"
|
|
109
|
+
lastDiagPath = if (decor == null) "decorView.null" else "contentView.null"
|
|
110
|
+
totalEmptyResultTicks++
|
|
79
111
|
return null
|
|
80
112
|
}
|
|
81
113
|
if (root.width <= 0 || root.height <= 0) {
|
|
82
114
|
lastDiagPath = "root.zero-size"
|
|
115
|
+
totalEmptyResultTicks++
|
|
83
116
|
return null
|
|
84
117
|
}
|
|
85
118
|
|
|
86
119
|
val maskedSet = maskedIds.toHashSet()
|
|
87
120
|
val nodes = JSONArray()
|
|
88
|
-
val rect = Rect()
|
|
89
121
|
val rootLoc = IntArray(2).also { root.getLocationInWindow(it) }
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
lastDiagPath = "activity.resumed"
|
|
93
|
-
lastDiagNodes = nodes.length()
|
|
122
|
+
val ctx = WalkContext(rootLoc = rootLoc, maskedSet = maskedSet)
|
|
123
|
+
walk(root, depth = 0, parentMasked = false, ctx = ctx, nodes = nodes)
|
|
94
124
|
|
|
95
125
|
val payload = JSONObject().apply {
|
|
96
126
|
put("ts", System.currentTimeMillis())
|
|
@@ -98,77 +128,136 @@ object SentoriReplayCapture {
|
|
|
98
128
|
put("height", root.height)
|
|
99
129
|
put("nodes", nodes)
|
|
100
130
|
}
|
|
101
|
-
|
|
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
|
|
102
141
|
}
|
|
103
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
|
+
*/
|
|
104
171
|
private fun walk(
|
|
105
172
|
view: View,
|
|
173
|
+
depth: Int,
|
|
106
174
|
parentMasked: Boolean,
|
|
107
|
-
|
|
108
|
-
rootLoc: IntArray,
|
|
109
|
-
scratch: Rect,
|
|
175
|
+
ctx: WalkContext,
|
|
110
176
|
nodes: JSONArray,
|
|
111
177
|
) {
|
|
112
178
|
if (nodes.length() >= MAX_NODES) return
|
|
179
|
+
if (depth >= MAX_DEPTH) return
|
|
113
180
|
if (view.visibility != View.VISIBLE || view.alpha < 0.01) return
|
|
114
181
|
|
|
182
|
+
if (depth > ctx.depthMax) ctx.depthMax = depth
|
|
183
|
+
|
|
115
184
|
val viewTag = view.tag as? String
|
|
116
|
-
val isThisMasked = viewTag != null && maskedSet.contains(viewTag)
|
|
185
|
+
val isThisMasked = viewTag != null && ctx.maskedSet.contains(viewTag)
|
|
117
186
|
val masked = parentMasked || isThisMasked
|
|
118
187
|
|
|
119
|
-
val loc = IntArray(2)
|
|
120
|
-
view.getLocationInWindow(loc)
|
|
121
|
-
val x = loc[0] - rootLoc[0]
|
|
122
|
-
val y = loc[1] - rootLoc[1]
|
|
123
188
|
val w = view.width
|
|
124
189
|
val h = view.height
|
|
125
|
-
if (w <= 0 || h <= 0) return
|
|
126
190
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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]
|
|
133
199
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
view is TextView && !view.text.isNullOrEmpty() -> {
|
|
141
|
-
node.put("kind", "text")
|
|
142
|
-
val text = view.text.toString().let { if (it.length > 200) it.substring(0, 200) else it }
|
|
143
|
-
node.put("text", text)
|
|
144
|
-
node.put("color", colorToHex(view.currentTextColor))
|
|
145
|
-
kindEmitted = true
|
|
146
|
-
}
|
|
147
|
-
view is EditText -> {
|
|
148
|
-
node.put("kind", "text")
|
|
149
|
-
val text = (view.text ?: "").toString().let { if (it.length > 200) it.substring(0, 200) else it }
|
|
150
|
-
node.put("text", text)
|
|
151
|
-
kindEmitted = true
|
|
152
|
-
}
|
|
153
|
-
view is ImageView -> {
|
|
154
|
-
node.put("kind", "image")
|
|
155
|
-
kindEmitted = true
|
|
200
|
+
val node = JSONObject().apply {
|
|
201
|
+
put("x", x)
|
|
202
|
+
put("y", y)
|
|
203
|
+
put("w", w)
|
|
204
|
+
put("h", h)
|
|
156
205
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
|
162
249
|
}
|
|
163
|
-
}
|
|
164
250
|
|
|
165
|
-
|
|
166
|
-
|
|
251
|
+
if (kindEmitted) {
|
|
252
|
+
nodes.put(node)
|
|
253
|
+
}
|
|
167
254
|
}
|
|
168
255
|
|
|
256
|
+
// Always recurse — even zero-size wrappers can host real
|
|
257
|
+
// descendants (the rc.3 fix).
|
|
169
258
|
if (!masked && view is ViewGroup) {
|
|
170
259
|
for (i in 0 until view.childCount) {
|
|
171
|
-
walk(view.getChildAt(i),
|
|
260
|
+
walk(view.getChildAt(i), depth + 1, masked, ctx, nodes)
|
|
172
261
|
}
|
|
173
262
|
}
|
|
174
263
|
}
|
|
@@ -180,4 +269,78 @@ object SentoriReplayCapture {
|
|
|
180
269
|
val b = c and 0xff
|
|
181
270
|
return String.format("#%02X%02X%02X%02X", r, g, b, a)
|
|
182
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
|
+
}
|
|
183
346
|
}
|