@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.
@@ -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
- @Volatile private var lastActivity: WeakReference<Activity>? = null
31
-
32
- // v0.9.12 — diagnostic readout so the JS side can ask "why is the
33
- // ring empty?" without parsing logcat. Mirrors the iOS side.
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 = lastActivity?.get()
53
+ val activity = SentoriForegroundActivity.current()
40
54
  return mapOf(
41
55
  "lastPath" to lastDiagPath,
42
56
  "lastNodes" to lastDiagNodes,
43
- "sceneCount" to if (activity != null) 1 else 0,
44
- "windowCount" to (activity?.window?.let { 1 } ?: 0),
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
- lastActivity = activity?.let { WeakReference(it) }
72
+ if (activity != null) SentoriForegroundActivity.set(activity, "manual.setActivity")
51
73
  }
52
74
 
53
- /** Attach an ActivityLifecycleCallbacks so future
54
- * `captureWireframe()` calls know which Activity to walk. */
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
- application.registerActivityLifecycleCallbacks(object :
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
- val activity = lastActivity?.get()
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
- walk(root, false, maskedSet, rootLoc, rect, nodes)
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
- return payload.toString()
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
- maskedSet: Set<String>,
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
- val node = JSONObject().apply {
128
- put("x", x)
129
- put("y", y)
130
- put("w", w)
131
- put("h", h)
132
- }
133
-
134
- var kindEmitted = false
135
- when {
136
- masked -> {
137
- node.put("kind", "mask")
138
- kindEmitted = true
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
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
- view is ImageView -> {
154
- node.put("kind", "image")
155
- kindEmitted = true
156
- }
157
- view.background != null -> {
158
- node.put("kind", "rect")
159
- // Background drawables don't always expose color directly.
160
- // Skip color for non-ColorDrawable; renderer falls back to neutral.
161
- kindEmitted = true
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
- if (kindEmitted) {
166
- nodes.put(node)
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), masked, maskedSet, rootLoc, scratch, nodes)
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
- /// Latest activity we've seen via `ActivityLifecycleCallbacks`
64
- /// used to find the window to screenshot when neither the JS
65
- /// side (which knows of `Activity.this` via React Native) nor
66
- /// the crash handler hands one to us.
67
- @Volatile private var lastActivity: WeakReference<Activity>? = null
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
- * Attach an `ActivityLifecycleCallbacks` so subsequent
71
- * `captureScreen()` calls know which Activity (and therefore
72
- * Window) to target. Idempotent; call from
73
- * `SentoriCrashHandler.register(context)`.
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
- application.registerActivityLifecycleCallbacks(object :
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 = lastActivity?.get() ?: return null
106
- val window = activity.window ?: return null
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 = lastActivity?.get() ?: return null
124
- val window = activity.window ?: return null
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
- if (w <= 0 || h <= 0) return null
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 (_: Throwable) {
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) return null
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
@@ -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()