@goliapkg/sentori-react-native 0.9.11 → 1.0.0-rc.2
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 +13 -0
- package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +41 -18
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
- package/ios/SentoriModule.swift +15 -0
- package/ios/SentoriReplayCapture.swift +103 -9
- package/ios/SentoriScreenshotCapture.swift +69 -3
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.bak +64 -0
- package/lib/index.js.map +1 -1
- package/lib/native.d.ts +57 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +99 -0
- package/lib/native.js.map +1 -1
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +75 -8
- package/lib/replay.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/native.ts +136 -0
- package/src/replay.ts +75 -7
|
@@ -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()
|
|
@@ -9,7 +9,6 @@ import android.widget.ImageView
|
|
|
9
9
|
import android.widget.TextView
|
|
10
10
|
import org.json.JSONArray
|
|
11
11
|
import org.json.JSONObject
|
|
12
|
-
import java.lang.ref.WeakReference
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* v0.9.6 #2 — wireframe session replay (Android side).
|
|
@@ -27,34 +26,55 @@ object SentoriReplayCapture {
|
|
|
27
26
|
|
|
28
27
|
private const val MAX_NODES = 800
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
// v0.9.12 — diagnostic readout so the JS side can ask "why is the
|
|
30
|
+
// ring empty?" without parsing logcat. Mirrors the iOS side.
|
|
31
|
+
@Volatile private var lastDiagPath: String = "none(not-yet-called)"
|
|
32
|
+
@Volatile private var lastDiagNodes: Int = 0
|
|
31
33
|
|
|
34
|
+
@JvmStatic
|
|
35
|
+
fun probe(): Map<String, Any> {
|
|
36
|
+
val activity = SentoriForegroundActivity.current()
|
|
37
|
+
return mapOf(
|
|
38
|
+
"lastPath" to lastDiagPath,
|
|
39
|
+
"lastNodes" to lastDiagNodes,
|
|
40
|
+
"trackedSource" to SentoriForegroundActivity.lastPath,
|
|
41
|
+
"trackedActivity" to (activity?.javaClass?.name ?: "null"),
|
|
42
|
+
"decorViewFound" to (activity?.window?.decorView != null),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Backwards compat — pre-rc.2 callers that hand-fed an Activity
|
|
47
|
+
* through `setActivity` still work; we forward to the shared
|
|
48
|
+
* tracker so screenshot + replay both see it. */
|
|
32
49
|
@JvmStatic
|
|
33
50
|
fun setActivity(activity: Activity?) {
|
|
34
|
-
|
|
51
|
+
if (activity != null) SentoriForegroundActivity.set(activity, "manual.setActivity")
|
|
35
52
|
}
|
|
36
53
|
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
54
|
+
/** Idempotent. Wires the replay helper into the shared tracker;
|
|
55
|
+
* kept as a public entrypoint for backwards compat with existing
|
|
56
|
+
* call sites. */
|
|
39
57
|
@JvmStatic
|
|
40
58
|
fun register(application: android.app.Application) {
|
|
41
|
-
|
|
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
|
-
})
|
|
59
|
+
SentoriForegroundActivity.install(application)
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
@JvmStatic
|
|
54
63
|
fun captureWireframe(maskedIds: List<String>): String? {
|
|
55
|
-
val activity =
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
val activity = SentoriForegroundActivity.current()
|
|
65
|
+
if (activity == null) {
|
|
66
|
+
lastDiagPath = "activity.null"
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
val root = activity.window?.decorView
|
|
70
|
+
if (root == null) {
|
|
71
|
+
lastDiagPath = "decorView.null"
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
if (root.width <= 0 || root.height <= 0) {
|
|
75
|
+
lastDiagPath = "root.zero-size"
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
58
78
|
|
|
59
79
|
val maskedSet = maskedIds.toHashSet()
|
|
60
80
|
val nodes = JSONArray()
|
|
@@ -62,6 +82,9 @@ object SentoriReplayCapture {
|
|
|
62
82
|
val rootLoc = IntArray(2).also { root.getLocationInWindow(it) }
|
|
63
83
|
walk(root, false, maskedSet, rootLoc, rect, nodes)
|
|
64
84
|
|
|
85
|
+
lastDiagPath = "ok(${SentoriForegroundActivity.lastPath})"
|
|
86
|
+
lastDiagNodes = nodes.length()
|
|
87
|
+
|
|
65
88
|
val payload = JSONObject().apply {
|
|
66
89
|
put("ts", System.currentTimeMillis())
|
|
67
90
|
put("width", root.width)
|
|
@@ -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
|
@@ -30,6 +30,21 @@ public class SentoriModule: Module {
|
|
|
30
30
|
return SentoriReplayCapture.captureWireframe(maskedIds: maskedIds)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// v0.9.12 — diagnostic readout for replay. Returns the last
|
|
34
|
+
// keyWindow resolution path + scene/window counts so a single
|
|
35
|
+
// JS-side button can answer "why is my ring empty?" without
|
|
36
|
+
// re-rolling the pod. See SentoriReplayCapture.swift.
|
|
37
|
+
Function("probeWireframe") { () -> [String: Any] in
|
|
38
|
+
return SentoriReplayCapture.probe()
|
|
39
|
+
}
|
|
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
|
+
|
|
33
48
|
// v0.9.4 #1 — Mobile Vitals exposure.
|
|
34
49
|
Function("markJsBridgeReady") {
|
|
35
50
|
SentoriMobileVitals.markJsBridgeReady()
|
|
@@ -29,8 +29,33 @@ import UIKit
|
|
|
29
29
|
return result
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/// Last path the keyWindow lookup took. Exposed to JS via
|
|
33
|
+
/// `probeWireframe()` so the failure-mode diagnostic in Metro can
|
|
34
|
+
/// tell scene-race from "no window at all" without re-rolling the
|
|
35
|
+
/// pod. Updated on every captureSync call.
|
|
36
|
+
@objc public static var lastDiagPath: String = "none(not-yet-called)"
|
|
37
|
+
@objc public static var lastDiagNodes: Int = 0
|
|
38
|
+
@objc public static var lastDiagSceneCount: Int = 0
|
|
39
|
+
@objc public static var lastDiagWindowCount: Int = 0
|
|
40
|
+
private static var loggedFirstResult = false
|
|
41
|
+
|
|
32
42
|
private static func captureSync(maskedIds: Set<String>) -> String? {
|
|
33
|
-
|
|
43
|
+
let (winOpt, path) = resolveKeyWindow()
|
|
44
|
+
lastDiagPath = path
|
|
45
|
+
lastDiagSceneCount = currentSceneCount()
|
|
46
|
+
lastDiagWindowCount = currentWindowCount()
|
|
47
|
+
guard let window = winOpt else {
|
|
48
|
+
if !loggedFirstResult {
|
|
49
|
+
NSLog(
|
|
50
|
+
"[sentori] wireframe: returning nil — keyWindow path=%@ scenes=%d windows=%d",
|
|
51
|
+
path,
|
|
52
|
+
lastDiagSceneCount,
|
|
53
|
+
lastDiagWindowCount
|
|
54
|
+
)
|
|
55
|
+
loggedFirstResult = true
|
|
56
|
+
}
|
|
57
|
+
return nil
|
|
58
|
+
}
|
|
34
59
|
var nodes: [[String: Any]] = []
|
|
35
60
|
walk(
|
|
36
61
|
view: window,
|
|
@@ -39,6 +64,17 @@ import UIKit
|
|
|
39
64
|
window: window,
|
|
40
65
|
nodes: &nodes
|
|
41
66
|
)
|
|
67
|
+
lastDiagNodes = nodes.count
|
|
68
|
+
if !loggedFirstResult {
|
|
69
|
+
NSLog(
|
|
70
|
+
"[sentori] wireframe: first capture ok — keyWindow path=%@ bounds=%.0fx%.0f nodes=%d",
|
|
71
|
+
path,
|
|
72
|
+
window.bounds.width,
|
|
73
|
+
window.bounds.height,
|
|
74
|
+
nodes.count
|
|
75
|
+
)
|
|
76
|
+
loggedFirstResult = true
|
|
77
|
+
}
|
|
42
78
|
let payload: [String: Any] = [
|
|
43
79
|
"ts": Int(Date().timeIntervalSince1970 * 1000),
|
|
44
80
|
"width": Double(window.bounds.width),
|
|
@@ -51,19 +87,77 @@ import UIKit
|
|
|
51
87
|
return nil
|
|
52
88
|
}
|
|
53
89
|
|
|
54
|
-
|
|
90
|
+
/// Four-tier window resolution. The previous single-pass loop
|
|
91
|
+
/// returned nil whenever the first connected scene was a
|
|
92
|
+
/// `.background` or `.unattached` SwiftUI/preview scene that had
|
|
93
|
+
/// no windows yet — common on iOS 26 cold-start where the JS
|
|
94
|
+
/// thread spins up the replay tick before scene activation
|
|
95
|
+
/// settles (the v0.9.6 default 1 Hz fires within ~200 ms).
|
|
96
|
+
private static func resolveKeyWindow() -> (UIWindow?, String) {
|
|
55
97
|
if #available(iOS 13.0, *) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
98
|
+
let scenes = Array(UIApplication.shared.connectedScenes)
|
|
99
|
+
// Pass 1: foregroundActive scene with a key window.
|
|
100
|
+
for scene in scenes where scene.activationState == .foregroundActive {
|
|
101
|
+
if let ws = scene as? UIWindowScene,
|
|
102
|
+
let key = ws.windows.first(where: { $0.isKeyWindow }) {
|
|
103
|
+
return (key, "scene.fg.key")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Pass 2: foregroundActive scene's first window (no key set yet).
|
|
107
|
+
for scene in scenes where scene.activationState == .foregroundActive {
|
|
108
|
+
if let ws = scene as? UIWindowScene, let win = ws.windows.first {
|
|
109
|
+
return (win, "scene.fg.first")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Pass 3: foregroundInactive (mid-transition) scene with any window.
|
|
113
|
+
for scene in scenes where scene.activationState == .foregroundInactive {
|
|
114
|
+
if let ws = scene as? UIWindowScene, let win = ws.windows.first {
|
|
115
|
+
return (win, "scene.fgi.first")
|
|
60
116
|
}
|
|
61
|
-
|
|
62
|
-
|
|
117
|
+
}
|
|
118
|
+
// Pass 4: any scene at all with windows.
|
|
119
|
+
for scene in scenes {
|
|
120
|
+
if let ws = scene as? UIWindowScene, let win = ws.windows.first {
|
|
121
|
+
return (win, "scene.any.first")
|
|
63
122
|
}
|
|
64
123
|
}
|
|
124
|
+
// Fallthrough → legacy windows list.
|
|
125
|
+
}
|
|
126
|
+
if let leg = UIApplication.shared.windows.first {
|
|
127
|
+
return (leg, "legacy.first")
|
|
65
128
|
}
|
|
66
|
-
return
|
|
129
|
+
return (nil, "none")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private static func currentSceneCount() -> Int {
|
|
133
|
+
if #available(iOS 13.0, *) {
|
|
134
|
+
return UIApplication.shared.connectedScenes.count
|
|
135
|
+
}
|
|
136
|
+
return 0
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private static func currentWindowCount() -> Int {
|
|
140
|
+
if #available(iOS 13.0, *) {
|
|
141
|
+
return UIApplication.shared.connectedScenes.reduce(0) { acc, scene in
|
|
142
|
+
acc + ((scene as? UIWindowScene)?.windows.count ?? 0)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return UIApplication.shared.windows.count
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// JS-side probe. Returns a dict the example/dashboard can render
|
|
149
|
+
/// to ask "why is the ring empty?" without parsing Metro logs.
|
|
150
|
+
/// `lastNodes == 0 && lastPath != "none"` means the window walk
|
|
151
|
+
/// happened but the tree was empty (unusual — backgrounded?).
|
|
152
|
+
/// `lastPath == "none(...)"` means no UIWindow was reachable at
|
|
153
|
+
/// the moment of the last tick.
|
|
154
|
+
@objc public static func probe() -> [String: Any] {
|
|
155
|
+
return [
|
|
156
|
+
"lastPath": lastDiagPath,
|
|
157
|
+
"lastNodes": lastDiagNodes,
|
|
158
|
+
"sceneCount": lastDiagSceneCount,
|
|
159
|
+
"windowCount": lastDiagWindowCount,
|
|
160
|
+
]
|
|
67
161
|
}
|
|
68
162
|
|
|
69
163
|
/// Cap on nodes per snapshot — extremely deep / wide trees can
|