@goliapkg/sentori-react-native 1.0.0-rc.1 → 1.0.0-rc.3
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 +138 -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/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.map +1 -1
- package/lib/replay.js +60 -0
- package/lib/replay.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/native.ts +102 -2
- package/src/replay.ts +65 -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,6 @@
|
|
|
1
1
|
package com.sentori
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
-
import android.graphics.Rect
|
|
5
4
|
import android.view.View
|
|
6
5
|
import android.view.ViewGroup
|
|
7
6
|
import android.widget.EditText
|
|
@@ -9,7 +8,6 @@ import android.widget.ImageView
|
|
|
9
8
|
import android.widget.TextView
|
|
10
9
|
import org.json.JSONArray
|
|
11
10
|
import org.json.JSONObject
|
|
12
|
-
import java.lang.ref.WeakReference
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
13
|
* v0.9.6 #2 — wireframe session replay (Android side).
|
|
@@ -26,71 +24,88 @@ import java.lang.ref.WeakReference
|
|
|
26
24
|
object SentoriReplayCapture {
|
|
27
25
|
|
|
28
26
|
private const val MAX_NODES = 800
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
27
|
+
private const val MAX_DEPTH = 60
|
|
28
|
+
|
|
29
|
+
// Diagnostic readouts. Mirrors the iOS side. Surfaced via
|
|
30
|
+
// `probe()` so JS can answer "why is the ring shallow?" without
|
|
31
|
+
// parsing logcat.
|
|
32
|
+
//
|
|
33
|
+
// v0.9.12: lastPath + lastNodes
|
|
34
|
+
// v1.0.0-rc.3:
|
|
35
|
+
// * lastDepthMax — deepest descendant the walker reached. If
|
|
36
|
+
// this stays at 2 or 3 we know the recursion bailed early
|
|
37
|
+
// (the rc.2 zero-size-bails-subtree bug).
|
|
38
|
+
// * lastSizeBytes — byte length of the serialised payload. ~50
|
|
39
|
+
// bytes per node is typical; a 1 KB result with 800 nodes
|
|
40
|
+
// would be a red flag.
|
|
41
|
+
// * totalTicks / lastEmptyResultTicks — lifetime counters for
|
|
42
|
+
// ring health, so a thin-but-non-null capture doesn't slip
|
|
43
|
+
// through unnoticed.
|
|
34
44
|
@Volatile private var lastDiagPath: String = "none(not-yet-called)"
|
|
35
45
|
@Volatile private var lastDiagNodes: Int = 0
|
|
46
|
+
@Volatile private var lastDiagDepthMax: Int = 0
|
|
47
|
+
@Volatile private var lastDiagSizeBytes: Int = 0
|
|
48
|
+
@Volatile private var totalTicks: Long = 0
|
|
49
|
+
@Volatile private var totalEmptyResultTicks: Long = 0
|
|
36
50
|
|
|
37
51
|
@JvmStatic
|
|
38
52
|
fun probe(): Map<String, Any> {
|
|
39
|
-
val activity =
|
|
53
|
+
val activity = SentoriForegroundActivity.current()
|
|
40
54
|
return mapOf(
|
|
41
55
|
"lastPath" to lastDiagPath,
|
|
42
56
|
"lastNodes" to lastDiagNodes,
|
|
43
|
-
"
|
|
44
|
-
"
|
|
57
|
+
"lastDepthMax" to lastDiagDepthMax,
|
|
58
|
+
"lastSizeBytes" to lastDiagSizeBytes,
|
|
59
|
+
"totalTicks" to totalTicks,
|
|
60
|
+
"totalEmptyResultTicks" to totalEmptyResultTicks,
|
|
61
|
+
"trackedSource" to SentoriForegroundActivity.lastPath,
|
|
62
|
+
"trackedActivity" to (activity?.javaClass?.name ?: "null"),
|
|
63
|
+
"decorViewFound" to (activity?.window?.decorView != null),
|
|
45
64
|
)
|
|
46
65
|
}
|
|
47
66
|
|
|
67
|
+
/** Backwards compat — pre-rc.2 callers that hand-fed an Activity
|
|
68
|
+
* through `setActivity` still work; we forward to the shared
|
|
69
|
+
* tracker so screenshot + replay both see it. */
|
|
48
70
|
@JvmStatic
|
|
49
71
|
fun setActivity(activity: Activity?) {
|
|
50
|
-
|
|
72
|
+
if (activity != null) SentoriForegroundActivity.set(activity, "manual.setActivity")
|
|
51
73
|
}
|
|
52
74
|
|
|
53
|
-
/**
|
|
54
|
-
*
|
|
75
|
+
/** Idempotent. Wires the replay helper into the shared tracker;
|
|
76
|
+
* kept as a public entrypoint for backwards compat with existing
|
|
77
|
+
* call sites. */
|
|
55
78
|
@JvmStatic
|
|
56
79
|
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
|
-
})
|
|
80
|
+
SentoriForegroundActivity.install(application)
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
@JvmStatic
|
|
70
84
|
fun captureWireframe(maskedIds: List<String>): String? {
|
|
71
|
-
|
|
85
|
+
totalTicks++
|
|
86
|
+
val activity = SentoriForegroundActivity.current()
|
|
72
87
|
if (activity == null) {
|
|
73
88
|
lastDiagPath = "activity.null"
|
|
89
|
+
totalEmptyResultTicks++
|
|
74
90
|
return null
|
|
75
91
|
}
|
|
76
92
|
val root = activity.window?.decorView
|
|
77
93
|
if (root == null) {
|
|
78
94
|
lastDiagPath = "decorView.null"
|
|
95
|
+
totalEmptyResultTicks++
|
|
79
96
|
return null
|
|
80
97
|
}
|
|
81
98
|
if (root.width <= 0 || root.height <= 0) {
|
|
82
99
|
lastDiagPath = "root.zero-size"
|
|
100
|
+
totalEmptyResultTicks++
|
|
83
101
|
return null
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
val maskedSet = maskedIds.toHashSet()
|
|
87
105
|
val nodes = JSONArray()
|
|
88
|
-
val rect = Rect()
|
|
89
106
|
val rootLoc = IntArray(2).also { root.getLocationInWindow(it) }
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
lastDiagPath = "activity.resumed"
|
|
93
|
-
lastDiagNodes = nodes.length()
|
|
107
|
+
val ctx = WalkContext(rootLoc = rootLoc, maskedSet = maskedSet)
|
|
108
|
+
walk(root, depth = 0, parentMasked = false, ctx = ctx, nodes = nodes)
|
|
94
109
|
|
|
95
110
|
val payload = JSONObject().apply {
|
|
96
111
|
put("ts", System.currentTimeMillis())
|
|
@@ -98,77 +113,123 @@ object SentoriReplayCapture {
|
|
|
98
113
|
put("height", root.height)
|
|
99
114
|
put("nodes", nodes)
|
|
100
115
|
}
|
|
101
|
-
|
|
116
|
+
val serialised = payload.toString()
|
|
117
|
+
|
|
118
|
+
lastDiagPath = "ok(${SentoriForegroundActivity.lastPath})"
|
|
119
|
+
lastDiagNodes = nodes.length()
|
|
120
|
+
lastDiagDepthMax = ctx.depthMax
|
|
121
|
+
lastDiagSizeBytes = serialised.length
|
|
122
|
+
|
|
123
|
+
if (nodes.length() == 0) totalEmptyResultTicks++
|
|
124
|
+
|
|
125
|
+
return serialised
|
|
102
126
|
}
|
|
103
127
|
|
|
128
|
+
/** Per-walk scratch: tracks the deepest descendant reached so
|
|
129
|
+
* the probe can surface whether the recursion ran or bailed.
|
|
130
|
+
* Bundled into one object to keep the recursive signature
|
|
131
|
+
* manageable. */
|
|
132
|
+
private class WalkContext(
|
|
133
|
+
val rootLoc: IntArray,
|
|
134
|
+
val maskedSet: Set<String>,
|
|
135
|
+
var depthMax: Int = 0,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Recursive walker.
|
|
140
|
+
*
|
|
141
|
+
* v1.0.0-rc.3 fix: previously this function returned ENTIRELY
|
|
142
|
+
* when the view itself had `width <= 0 || height <= 0`. That
|
|
143
|
+
* meant any ViewGroup wrapper that happened to measure to zero
|
|
144
|
+
* size during the tick (common on Fabric / RN's intermediate
|
|
145
|
+
* shadow-tree wrappers, and on lazy-layout phases) skipped the
|
|
146
|
+
* whole descendant subtree — Insight 2026-05-17 verify event
|
|
147
|
+
* saw 800-node frames whose subtree was actually thousands of
|
|
148
|
+
* Views deep but only the root + 2-3 wrappers made it into the
|
|
149
|
+
* JSON.
|
|
150
|
+
*
|
|
151
|
+
* Now we separate "emit a node for this view" from "recurse into
|
|
152
|
+
* its children". A zero-size view doesn't get an emitted node
|
|
153
|
+
* (no visual contribution) but its descendants still get walked
|
|
154
|
+
* — they may have real frames.
|
|
155
|
+
*/
|
|
104
156
|
private fun walk(
|
|
105
157
|
view: View,
|
|
158
|
+
depth: Int,
|
|
106
159
|
parentMasked: Boolean,
|
|
107
|
-
|
|
108
|
-
rootLoc: IntArray,
|
|
109
|
-
scratch: Rect,
|
|
160
|
+
ctx: WalkContext,
|
|
110
161
|
nodes: JSONArray,
|
|
111
162
|
) {
|
|
112
163
|
if (nodes.length() >= MAX_NODES) return
|
|
164
|
+
if (depth >= MAX_DEPTH) return
|
|
113
165
|
if (view.visibility != View.VISIBLE || view.alpha < 0.01) return
|
|
114
166
|
|
|
167
|
+
if (depth > ctx.depthMax) ctx.depthMax = depth
|
|
168
|
+
|
|
115
169
|
val viewTag = view.tag as? String
|
|
116
|
-
val isThisMasked = viewTag != null && maskedSet.contains(viewTag)
|
|
170
|
+
val isThisMasked = viewTag != null && ctx.maskedSet.contains(viewTag)
|
|
117
171
|
val masked = parentMasked || isThisMasked
|
|
118
172
|
|
|
119
|
-
val loc = IntArray(2)
|
|
120
|
-
view.getLocationInWindow(loc)
|
|
121
|
-
val x = loc[0] - rootLoc[0]
|
|
122
|
-
val y = loc[1] - rootLoc[1]
|
|
123
173
|
val w = view.width
|
|
124
174
|
val h = view.height
|
|
125
|
-
if (w <= 0 || h <= 0) return
|
|
126
175
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
176
|
+
// Emit a node ONLY when the view has visual extent. A zero-
|
|
177
|
+
// size view contributes nothing to render but its subtree
|
|
178
|
+
// might; recurse below regardless.
|
|
179
|
+
if (w > 0 && h > 0) {
|
|
180
|
+
val loc = IntArray(2)
|
|
181
|
+
view.getLocationInWindow(loc)
|
|
182
|
+
val x = loc[0] - ctx.rootLoc[0]
|
|
183
|
+
val y = loc[1] - ctx.rootLoc[1]
|
|
184
|
+
|
|
185
|
+
val node = JSONObject().apply {
|
|
186
|
+
put("x", x)
|
|
187
|
+
put("y", y)
|
|
188
|
+
put("w", w)
|
|
189
|
+
put("h", h)
|
|
152
190
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
191
|
+
|
|
192
|
+
var kindEmitted = false
|
|
193
|
+
when {
|
|
194
|
+
masked -> {
|
|
195
|
+
node.put("kind", "mask")
|
|
196
|
+
kindEmitted = true
|
|
197
|
+
}
|
|
198
|
+
view is TextView && !view.text.isNullOrEmpty() -> {
|
|
199
|
+
node.put("kind", "text")
|
|
200
|
+
val text = view.text.toString().let { if (it.length > 200) it.substring(0, 200) else it }
|
|
201
|
+
node.put("text", text)
|
|
202
|
+
node.put("color", colorToHex(view.currentTextColor))
|
|
203
|
+
kindEmitted = true
|
|
204
|
+
}
|
|
205
|
+
view is EditText -> {
|
|
206
|
+
node.put("kind", "text")
|
|
207
|
+
val text = (view.text ?: "").toString().let { if (it.length > 200) it.substring(0, 200) else it }
|
|
208
|
+
node.put("text", text)
|
|
209
|
+
kindEmitted = true
|
|
210
|
+
}
|
|
211
|
+
view is ImageView -> {
|
|
212
|
+
node.put("kind", "image")
|
|
213
|
+
kindEmitted = true
|
|
214
|
+
}
|
|
215
|
+
view.background != null -> {
|
|
216
|
+
node.put("kind", "rect")
|
|
217
|
+
// Background drawables don't always expose color directly.
|
|
218
|
+
// Skip color for non-ColorDrawable; renderer falls back to neutral.
|
|
219
|
+
kindEmitted = true
|
|
220
|
+
}
|
|
162
221
|
}
|
|
163
|
-
}
|
|
164
222
|
|
|
165
|
-
|
|
166
|
-
|
|
223
|
+
if (kindEmitted) {
|
|
224
|
+
nodes.put(node)
|
|
225
|
+
}
|
|
167
226
|
}
|
|
168
227
|
|
|
228
|
+
// Always recurse — even zero-size wrappers can host real
|
|
229
|
+
// descendants (the rc.3 fix).
|
|
169
230
|
if (!masked && view is ViewGroup) {
|
|
170
231
|
for (i in 0 until view.childCount) {
|
|
171
|
-
walk(view.getChildAt(i),
|
|
232
|
+
walk(view.getChildAt(i), depth + 1, masked, ctx, nodes)
|
|
172
233
|
}
|
|
173
234
|
}
|
|
174
235
|
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
package com.sentori
|
|
2
2
|
|
|
3
|
-
import android.app.Activity
|
|
4
3
|
import android.app.Application
|
|
5
4
|
import android.graphics.Bitmap
|
|
6
5
|
import android.graphics.Canvas
|
|
7
6
|
import android.graphics.Color
|
|
8
7
|
import android.graphics.Paint
|
|
9
8
|
import android.os.Build
|
|
10
|
-
import android.os.Bundle
|
|
11
9
|
import android.os.Handler
|
|
12
10
|
import android.os.HandlerThread
|
|
13
11
|
import android.util.Base64
|
|
@@ -16,7 +14,6 @@ import android.view.View
|
|
|
16
14
|
import android.view.ViewGroup
|
|
17
15
|
import android.view.Window
|
|
18
16
|
import java.io.ByteArrayOutputStream
|
|
19
|
-
import java.lang.ref.WeakReference
|
|
20
17
|
import java.util.concurrent.CountDownLatch
|
|
21
18
|
import java.util.concurrent.TimeUnit
|
|
22
19
|
import org.json.JSONArray
|
|
@@ -60,36 +57,44 @@ object SentoriScreenshotCapture {
|
|
|
60
57
|
private const val MAX_NODES = 1500
|
|
61
58
|
private const val PIXEL_COPY_TIMEOUT_MS = 200L
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@Volatile private var
|
|
60
|
+
// v1.0.0-rc.2 — diagnostic readout so the JS side can ask
|
|
61
|
+
// "why did screenshot return null" without parsing logcat. Mirrors
|
|
62
|
+
// SentoriReplayCapture.probe() and the iOS Swift probe.
|
|
63
|
+
@Volatile private var lastDiagPath: String = "none(not-yet-called)"
|
|
64
|
+
@Volatile private var lastDiagW: Int = 0
|
|
65
|
+
@Volatile private var lastDiagH: Int = 0
|
|
68
66
|
|
|
69
67
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* `
|
|
68
|
+
* Snapshot of the most recent capture attempt — what code path
|
|
69
|
+
* resolved an Activity, what the decor view's dimensions were,
|
|
70
|
+
* and what call source the foreground tracker last saw the
|
|
71
|
+
* Activity from. Used by `probeNativeScreenshot()` on the JS
|
|
72
|
+
* side so Insight can ship raw diagnostic state back without
|
|
73
|
+
* needing logcat access.
|
|
74
|
+
*/
|
|
75
|
+
@JvmStatic
|
|
76
|
+
fun probe(): Map<String, Any> {
|
|
77
|
+
val tracked = SentoriForegroundActivity.current()
|
|
78
|
+
return mapOf(
|
|
79
|
+
"lastPath" to lastDiagPath,
|
|
80
|
+
"lastWidth" to lastDiagW,
|
|
81
|
+
"lastHeight" to lastDiagH,
|
|
82
|
+
"trackedSource" to SentoriForegroundActivity.lastPath,
|
|
83
|
+
"trackedActivity" to (tracked?.javaClass?.name ?: "null"),
|
|
84
|
+
"decorViewFound" to (tracked?.window?.decorView != null),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Idempotent. Wires the screenshot helper into the shared
|
|
90
|
+
* foreground-activity tracker; kept as a public entrypoint for
|
|
91
|
+
* backwards compat with existing call sites (the crash handler
|
|
92
|
+
* still calls this), but the actual lifecycle subscription lives
|
|
93
|
+
* in [SentoriForegroundActivity].
|
|
74
94
|
*/
|
|
75
95
|
@JvmStatic
|
|
76
96
|
fun register(application: Application) {
|
|
77
|
-
|
|
78
|
-
Application.ActivityLifecycleCallbacks {
|
|
79
|
-
override fun onActivityCreated(a: Activity, b: Bundle?) {
|
|
80
|
-
lastActivity = WeakReference(a)
|
|
81
|
-
}
|
|
82
|
-
override fun onActivityStarted(a: Activity) {
|
|
83
|
-
lastActivity = WeakReference(a)
|
|
84
|
-
}
|
|
85
|
-
override fun onActivityResumed(a: Activity) {
|
|
86
|
-
lastActivity = WeakReference(a)
|
|
87
|
-
}
|
|
88
|
-
override fun onActivityPaused(a: Activity) {}
|
|
89
|
-
override fun onActivityStopped(a: Activity) {}
|
|
90
|
-
override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
|
|
91
|
-
override fun onActivityDestroyed(a: Activity) {}
|
|
92
|
-
})
|
|
97
|
+
SentoriForegroundActivity.install(application)
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
/**
|
|
@@ -102,8 +107,16 @@ object SentoriScreenshotCapture {
|
|
|
102
107
|
*/
|
|
103
108
|
@JvmStatic
|
|
104
109
|
fun captureKeyWindow(): Map<String, Any>? {
|
|
105
|
-
val activity =
|
|
106
|
-
|
|
110
|
+
val activity = SentoriForegroundActivity.current()
|
|
111
|
+
if (activity == null) {
|
|
112
|
+
lastDiagPath = "activity.null"
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
val window = activity.window
|
|
116
|
+
if (window == null) {
|
|
117
|
+
lastDiagPath = "window.null"
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
107
120
|
val out = mutableMapOf<String, Any>()
|
|
108
121
|
captureScreen(window, emptySet())?.let { (base64, mediaType) ->
|
|
109
122
|
out["screenshot"] = mapOf("base64" to base64, "mediaType" to mediaType)
|
|
@@ -120,8 +133,16 @@ object SentoriScreenshotCapture {
|
|
|
120
133
|
/// masked subview's frame on the captured bitmap.
|
|
121
134
|
@JvmStatic
|
|
122
135
|
fun captureScreenshotWithMask(maskedIds: List<String>): Map<String, String>? {
|
|
123
|
-
val activity =
|
|
124
|
-
|
|
136
|
+
val activity = SentoriForegroundActivity.current()
|
|
137
|
+
if (activity == null) {
|
|
138
|
+
lastDiagPath = "activity.null"
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
val window = activity.window
|
|
142
|
+
if (window == null) {
|
|
143
|
+
lastDiagPath = "window.null"
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
125
146
|
val (base64, mediaType) = captureScreen(window, maskedIds.toHashSet()) ?: return null
|
|
126
147
|
return mapOf("base64" to base64, "mediaType" to mediaType)
|
|
127
148
|
}
|
|
@@ -135,12 +156,22 @@ object SentoriScreenshotCapture {
|
|
|
135
156
|
// requires the activity not to be torn down. Skip for
|
|
136
157
|
// now; v0.6.1 SDK can add the fallback if real-world
|
|
137
158
|
// data shows we have users below API 24.
|
|
159
|
+
lastDiagPath = "api.unsupported"
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
val decor = window.decorView
|
|
163
|
+
if (decor == null) {
|
|
164
|
+
lastDiagPath = "decorView.null"
|
|
138
165
|
return null
|
|
139
166
|
}
|
|
140
|
-
val decor = window.decorView ?: return null
|
|
141
167
|
val w = decor.width
|
|
142
168
|
val h = decor.height
|
|
143
|
-
|
|
169
|
+
lastDiagW = w
|
|
170
|
+
lastDiagH = h
|
|
171
|
+
if (w <= 0 || h <= 0) {
|
|
172
|
+
lastDiagPath = "decorView.zero-size"
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
144
175
|
|
|
145
176
|
// Long-edge scale.
|
|
146
177
|
val longEdge = maxOf(w, h).toFloat()
|
|
@@ -175,12 +206,17 @@ object SentoriScreenshotCapture {
|
|
|
175
206
|
)
|
|
176
207
|
}
|
|
177
208
|
latch.await(PIXEL_COPY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
|
178
|
-
} catch (
|
|
209
|
+
} catch (t: Throwable) {
|
|
210
|
+
lastDiagPath = "pixelCopy.threw:${t.javaClass.simpleName}"
|
|
179
211
|
return null
|
|
180
212
|
} finally {
|
|
181
213
|
handlerThread.quitSafely()
|
|
182
214
|
}
|
|
183
|
-
if (!success)
|
|
215
|
+
if (!success) {
|
|
216
|
+
lastDiagPath = "pixelCopy.notSuccess"
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
lastDiagPath = "ok"
|
|
184
220
|
|
|
185
221
|
// v0.7.3 — paint black rectangles over masked subviews on the
|
|
186
222
|
// already-captured bitmap. We get window-relative coordinates
|
package/ios/SentoriModule.swift
CHANGED
|
@@ -38,6 +38,13 @@ public class SentoriModule: Module {
|
|
|
38
38
|
return SentoriReplayCapture.probe()
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// v1.0.0-rc.2 — diagnostic readout for screenshot. Same shape
|
|
42
|
+
// as Android side; lets Insight ship raw state back when the
|
|
43
|
+
// captureScreenshot path returns null.
|
|
44
|
+
Function("probeScreenshot") { () -> [String: Any] in
|
|
45
|
+
return SentoriScreenshotCapture.probe()
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
// v0.9.4 #1 — Mobile Vitals exposure.
|
|
42
49
|
Function("markJsBridgeReady") {
|
|
43
50
|
SentoriMobileVitals.markJsBridgeReady()
|