@goliapkg/sentori-react-native 0.5.7 → 0.6.1
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/SentoriAnrWatchdog.kt +46 -0
- package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +53 -0
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +271 -0
- package/android/src/test/java/com/sentori/SentoriScreenshotCaptureTest.kt +93 -0
- package/ios/SentoriCrashHandler.swift +33 -1
- package/ios/SentoriScreenshotCapture.swift +169 -0
- package/ios/Tests/SentoriScreenshotCaptureTests.swift +59 -0
- package/lib/capture.d.ts +6 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +65 -9
- package/lib/capture.js.map +1 -1
- package/lib/config.d.ts +2 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/handlers/screenshot.d.ts +12 -0
- package/lib/handlers/screenshot.d.ts.map +1 -0
- package/lib/handlers/screenshot.js +85 -0
- package/lib/handlers/screenshot.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +7 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +21 -3
- package/lib/init.js.map +1 -1
- package/lib/mask.d.ts +30 -0
- package/lib/mask.d.ts.map +1 -0
- package/lib/mask.js +77 -0
- package/lib/mask.js.map +1 -0
- package/lib/transport.d.ts +22 -0
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +62 -0
- package/lib/transport.js.map +1 -1
- package/lib/types.d.ts +1 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +9 -4
- package/src/__tests__/screenshot.test.ts +88 -0
- package/src/capture.ts +79 -9
- package/src/config.ts +2 -0
- package/src/handlers/screenshot.ts +115 -0
- package/src/index.ts +5 -0
- package/src/init.ts +55 -4
- package/src/mask.tsx +95 -0
- package/src/transport.ts +77 -0
- package/src/types.ts +3 -0
|
@@ -104,6 +104,11 @@ object SentoriAnrWatchdog {
|
|
|
104
104
|
try {
|
|
105
105
|
val mainStack = Looper.getMainLooper().thread.stackTrace
|
|
106
106
|
val event = buildAnrEvent(ctx, mainStack)
|
|
107
|
+
// Phase 42 sub-F.07: ANR by definition means main thread
|
|
108
|
+
// is wedged — but PixelCopy runs on a HandlerThread, so we
|
|
109
|
+
// can still snapshot the (frozen) UI. Attach screenshot
|
|
110
|
+
// + view tree like the crash path.
|
|
111
|
+
attachPending(event)
|
|
107
112
|
val dir = File(ctx.filesDir, PENDING_DIR_NAME)
|
|
108
113
|
if (!dir.exists()) dir.mkdirs()
|
|
109
114
|
val file = File(dir, "${uuid()}.json")
|
|
@@ -114,6 +119,47 @@ object SentoriAnrWatchdog {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
|
|
122
|
+
private fun attachPending(event: JSONObject) {
|
|
123
|
+
val snap = try {
|
|
124
|
+
SentoriScreenshotCapture.captureKeyWindow()
|
|
125
|
+
} catch (_: Throwable) {
|
|
126
|
+
null
|
|
127
|
+
} ?: return
|
|
128
|
+
val pending = JSONArray()
|
|
129
|
+
|
|
130
|
+
@Suppress("UNCHECKED_CAST")
|
|
131
|
+
val sc = snap["screenshot"] as? Map<String, Any>
|
|
132
|
+
if (sc != null) {
|
|
133
|
+
val b64 = sc["base64"] as? String
|
|
134
|
+
val mt = (sc["mediaType"] as? String) ?: "image/webp"
|
|
135
|
+
if (b64 != null) {
|
|
136
|
+
pending.put(JSONObject().apply {
|
|
137
|
+
put("kind", "screenshot")
|
|
138
|
+
put("base64", b64)
|
|
139
|
+
put("mediaType", mt)
|
|
140
|
+
put("source", "android")
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
val vt = snap["viewTree"]
|
|
145
|
+
if (vt != null) {
|
|
146
|
+
val asJson = SentoriScreenshotCapture.toJson(vt)
|
|
147
|
+
val base64 = android.util.Base64.encodeToString(
|
|
148
|
+
asJson.toString().toByteArray(Charsets.UTF_8),
|
|
149
|
+
android.util.Base64.NO_WRAP,
|
|
150
|
+
)
|
|
151
|
+
pending.put(JSONObject().apply {
|
|
152
|
+
put("kind", "viewTree")
|
|
153
|
+
put("base64", base64)
|
|
154
|
+
put("mediaType", "application/json")
|
|
155
|
+
put("source", "android")
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
if (pending.length() > 0) {
|
|
159
|
+
event.put("_pendingAttachments", pending)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
117
163
|
private fun buildAnrEvent(ctx: Context, mainStack: Array<StackTraceElement>): JSONObject {
|
|
118
164
|
val cfg = configMap(ctx)
|
|
119
165
|
val release = cfg["release"] ?: "unknown"
|
|
@@ -2,6 +2,7 @@ package com.sentori
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.os.Build
|
|
5
|
+
import android.util.Base64
|
|
5
6
|
import org.json.JSONArray
|
|
6
7
|
import org.json.JSONObject
|
|
7
8
|
import java.io.File
|
|
@@ -42,6 +43,11 @@ object SentoriCrashHandler {
|
|
|
42
43
|
}
|
|
43
44
|
previousHandler?.uncaughtException(thread, throwable)
|
|
44
45
|
}
|
|
46
|
+
// Phase 42 sub-F.01: have the screenshot helper track the
|
|
47
|
+
// foreground Activity so it knows which Window to PixelCopy.
|
|
48
|
+
(appCtx as? android.app.Application)?.let {
|
|
49
|
+
SentoriScreenshotCapture.register(it)
|
|
50
|
+
}
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
@JvmStatic
|
|
@@ -131,6 +137,11 @@ object SentoriCrashHandler {
|
|
|
131
137
|
put("spanId", JSONObject.NULL)
|
|
132
138
|
}
|
|
133
139
|
|
|
140
|
+
// Phase 42 sub-F.05/08: capture screen + view tree before the
|
|
141
|
+
// app dies, attach as `_pendingAttachments` for the JS side to
|
|
142
|
+
// upload on next launch (same shape as iOS sub-E).
|
|
143
|
+
attachPending(event)
|
|
144
|
+
|
|
134
145
|
val dir = pendingDir() ?: return
|
|
135
146
|
val file = File(dir, "${uuidLower()}.json")
|
|
136
147
|
try {
|
|
@@ -140,6 +151,48 @@ object SentoriCrashHandler {
|
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
|
|
154
|
+
private fun attachPending(event: JSONObject) {
|
|
155
|
+
val snap = try {
|
|
156
|
+
SentoriScreenshotCapture.captureKeyWindow()
|
|
157
|
+
} catch (_: Throwable) {
|
|
158
|
+
null
|
|
159
|
+
} ?: return
|
|
160
|
+
val pending = JSONArray()
|
|
161
|
+
|
|
162
|
+
@Suppress("UNCHECKED_CAST")
|
|
163
|
+
val sc = snap["screenshot"] as? Map<String, Any>
|
|
164
|
+
if (sc != null) {
|
|
165
|
+
val b64 = sc["base64"] as? String
|
|
166
|
+
val mt = (sc["mediaType"] as? String) ?: "image/webp"
|
|
167
|
+
if (b64 != null) {
|
|
168
|
+
pending.put(JSONObject().apply {
|
|
169
|
+
put("kind", "screenshot")
|
|
170
|
+
put("base64", b64)
|
|
171
|
+
put("mediaType", mt)
|
|
172
|
+
put("source", "android")
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
val vt = snap["viewTree"]
|
|
177
|
+
if (vt != null) {
|
|
178
|
+
// Convert Map → JSONObject → string → base64
|
|
179
|
+
val asJson = SentoriScreenshotCapture.toJson(vt)
|
|
180
|
+
val base64 = Base64.encodeToString(
|
|
181
|
+
asJson.toString().toByteArray(Charsets.UTF_8),
|
|
182
|
+
Base64.NO_WRAP,
|
|
183
|
+
)
|
|
184
|
+
pending.put(JSONObject().apply {
|
|
185
|
+
put("kind", "viewTree")
|
|
186
|
+
put("base64", base64)
|
|
187
|
+
put("mediaType", "application/json")
|
|
188
|
+
put("source", "android")
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
if (pending.length() > 0) {
|
|
192
|
+
event.put("_pendingAttachments", pending)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
private fun errorToJson(throwable: Throwable): JSONObject {
|
|
144
197
|
return JSONObject().apply {
|
|
145
198
|
put("type", throwable.javaClass.name)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
package com.sentori
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.Application
|
|
5
|
+
import android.graphics.Bitmap
|
|
6
|
+
import android.graphics.Canvas
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.os.Bundle
|
|
9
|
+
import android.os.Handler
|
|
10
|
+
import android.os.HandlerThread
|
|
11
|
+
import android.util.Base64
|
|
12
|
+
import android.view.PixelCopy
|
|
13
|
+
import android.view.View
|
|
14
|
+
import android.view.ViewGroup
|
|
15
|
+
import android.view.Window
|
|
16
|
+
import java.io.ByteArrayOutputStream
|
|
17
|
+
import java.lang.ref.WeakReference
|
|
18
|
+
import java.util.concurrent.CountDownLatch
|
|
19
|
+
import java.util.concurrent.TimeUnit
|
|
20
|
+
import org.json.JSONArray
|
|
21
|
+
import org.json.JSONObject
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Phase 42 sub-F.01/02/08 — capture the current activity's screen +
|
|
25
|
+
* view tree at native crash / ANR time.
|
|
26
|
+
*
|
|
27
|
+
* Lives separately from `SentoriCrashHandler` so we can also invoke
|
|
28
|
+
* it from `SentoriAnrWatchdog` (sub-F.07: ANR detector fires →
|
|
29
|
+
* snapshot main thread state → enqueue with the ANR event).
|
|
30
|
+
*
|
|
31
|
+
* The Android side of this story is harder than iOS:
|
|
32
|
+
* - iOS NSException fires on the main thread before tear-down → we
|
|
33
|
+
* can drive UIKit synchronously.
|
|
34
|
+
* - On Android, `Thread.UncaughtExceptionHandler` is on whatever
|
|
35
|
+
* thread crashed, *not* always the main one. Even on main, the
|
|
36
|
+
* activity might be partially torn down by the time we run.
|
|
37
|
+
* - `View.draw(Canvas)` works on the main thread (needs the view's
|
|
38
|
+
* RenderNode to be live); we use `PixelCopy.request` (API 24+)
|
|
39
|
+
* instead because it's GPU-driven, non-blocking on main, and
|
|
40
|
+
* produces a Bitmap even if the main thread is wedged (sub-F.07
|
|
41
|
+
* ANR path needs this).
|
|
42
|
+
* - Bitmap.compress(WEBP_LOSSY, ...) is Android 11+ only. We pick
|
|
43
|
+
* it when available, fall back to JPEG q=70 below 30.
|
|
44
|
+
*
|
|
45
|
+
* Output mirrors the iOS Swift helper + the sub-G dashboard schema:
|
|
46
|
+
*
|
|
47
|
+
* {
|
|
48
|
+
* "screenshot": { "base64": "...", "mediaType": "image/webp|jpeg" },
|
|
49
|
+
* "viewTree": { "rootId": "n1", "nodes": { ... } }
|
|
50
|
+
* }
|
|
51
|
+
*/
|
|
52
|
+
object SentoriScreenshotCapture {
|
|
53
|
+
|
|
54
|
+
private const val MAX_LONG_EDGE_PX = 480
|
|
55
|
+
private const val WEBP_QUALITY = 70
|
|
56
|
+
private const val JPEG_QUALITY = 70
|
|
57
|
+
private const val MAX_TREE_DEPTH = 10
|
|
58
|
+
private const val MAX_NODES = 1500
|
|
59
|
+
private const val PIXEL_COPY_TIMEOUT_MS = 200L
|
|
60
|
+
|
|
61
|
+
/// Latest activity we've seen via `ActivityLifecycleCallbacks` —
|
|
62
|
+
/// used to find the window to screenshot when neither the JS
|
|
63
|
+
/// side (which knows of `Activity.this` via React Native) nor
|
|
64
|
+
/// the crash handler hands one to us.
|
|
65
|
+
@Volatile private var lastActivity: WeakReference<Activity>? = null
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Attach an `ActivityLifecycleCallbacks` so subsequent
|
|
69
|
+
* `captureScreen()` calls know which Activity (and therefore
|
|
70
|
+
* Window) to target. Idempotent; call from
|
|
71
|
+
* `SentoriCrashHandler.register(context)`.
|
|
72
|
+
*/
|
|
73
|
+
@JvmStatic
|
|
74
|
+
fun register(application: Application) {
|
|
75
|
+
application.registerActivityLifecycleCallbacks(object :
|
|
76
|
+
Application.ActivityLifecycleCallbacks {
|
|
77
|
+
override fun onActivityCreated(a: Activity, b: Bundle?) {
|
|
78
|
+
lastActivity = WeakReference(a)
|
|
79
|
+
}
|
|
80
|
+
override fun onActivityStarted(a: Activity) {
|
|
81
|
+
lastActivity = WeakReference(a)
|
|
82
|
+
}
|
|
83
|
+
override fun onActivityResumed(a: Activity) {
|
|
84
|
+
lastActivity = WeakReference(a)
|
|
85
|
+
}
|
|
86
|
+
override fun onActivityPaused(a: Activity) {}
|
|
87
|
+
override fun onActivityStopped(a: Activity) {}
|
|
88
|
+
override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
|
|
89
|
+
override fun onActivityDestroyed(a: Activity) {}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Top-level entry. Returns a JSON-shape `{screenshot, viewTree}`
|
|
95
|
+
* map, or `null` if the activity is gone / API < 24 / capture
|
|
96
|
+
* timed out. Safe to call from any thread; the PixelCopy request
|
|
97
|
+
* itself runs on its own HandlerThread so the calling thread
|
|
98
|
+
* (main during NSException-equivalent / ANR detector) doesn't
|
|
99
|
+
* block on the GPU.
|
|
100
|
+
*/
|
|
101
|
+
@JvmStatic
|
|
102
|
+
fun captureKeyWindow(): Map<String, Any>? {
|
|
103
|
+
val activity = lastActivity?.get() ?: return null
|
|
104
|
+
val window = activity.window ?: return null
|
|
105
|
+
val out = mutableMapOf<String, Any>()
|
|
106
|
+
captureScreen(window)?.let { (base64, mediaType) ->
|
|
107
|
+
out["screenshot"] = mapOf("base64" to base64, "mediaType" to mediaType)
|
|
108
|
+
}
|
|
109
|
+
out["viewTree"] = walkTree(window.decorView)
|
|
110
|
+
return if (out.isEmpty()) null else out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── screenshot ────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
private fun captureScreen(window: Window): Pair<String, String>? {
|
|
116
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
117
|
+
// PixelCopy is API 24+. Older Android: fall back to a
|
|
118
|
+
// `View.draw(Canvas)` path that *must* run on main and
|
|
119
|
+
// requires the activity not to be torn down. Skip for
|
|
120
|
+
// now; v0.6.1 SDK can add the fallback if real-world
|
|
121
|
+
// data shows we have users below API 24.
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
val decor = window.decorView ?: return null
|
|
125
|
+
val w = decor.width
|
|
126
|
+
val h = decor.height
|
|
127
|
+
if (w <= 0 || h <= 0) return null
|
|
128
|
+
|
|
129
|
+
// Long-edge scale.
|
|
130
|
+
val longEdge = maxOf(w, h).toFloat()
|
|
131
|
+
val scale = if (longEdge > MAX_LONG_EDGE_PX) MAX_LONG_EDGE_PX / longEdge else 1f
|
|
132
|
+
val outW = (w * scale).toInt().coerceAtLeast(1)
|
|
133
|
+
val outH = (h * scale).toInt().coerceAtLeast(1)
|
|
134
|
+
val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888)
|
|
135
|
+
|
|
136
|
+
val latch = CountDownLatch(1)
|
|
137
|
+
var success = false
|
|
138
|
+
val handlerThread = HandlerThread("sentori-pixel-copy").apply { start() }
|
|
139
|
+
val handler = Handler(handlerThread.looper)
|
|
140
|
+
try {
|
|
141
|
+
// Render the live window into our smaller Bitmap. PixelCopy
|
|
142
|
+
// does the scale internally (`request(Window, Rect, Bitmap, ...)`
|
|
143
|
+
// signature on API 26+; on 24/25 we use the rectless variant
|
|
144
|
+
// and accept the unscaled bitmap, then downscale ourselves).
|
|
145
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
146
|
+
PixelCopy.request(
|
|
147
|
+
window,
|
|
148
|
+
bitmap,
|
|
149
|
+
{ result -> success = result == PixelCopy.SUCCESS; latch.countDown() },
|
|
150
|
+
handler,
|
|
151
|
+
)
|
|
152
|
+
} else {
|
|
153
|
+
@Suppress("DEPRECATION")
|
|
154
|
+
PixelCopy.request(
|
|
155
|
+
window,
|
|
156
|
+
bitmap,
|
|
157
|
+
{ result -> success = result == PixelCopy.SUCCESS; latch.countDown() },
|
|
158
|
+
handler,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
latch.await(PIXEL_COPY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
|
162
|
+
} catch (_: Throwable) {
|
|
163
|
+
return null
|
|
164
|
+
} finally {
|
|
165
|
+
handlerThread.quitSafely()
|
|
166
|
+
}
|
|
167
|
+
if (!success) return null
|
|
168
|
+
|
|
169
|
+
val baos = ByteArrayOutputStream(64 * 1024)
|
|
170
|
+
val mediaType: String
|
|
171
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
172
|
+
// Android 11+: native WEBP_LOSSY ~30% smaller than JPEG q=70.
|
|
173
|
+
bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, WEBP_QUALITY, baos)
|
|
174
|
+
mediaType = "image/webp"
|
|
175
|
+
} else {
|
|
176
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos)
|
|
177
|
+
mediaType = "image/jpeg"
|
|
178
|
+
}
|
|
179
|
+
bitmap.recycle()
|
|
180
|
+
val base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
|
181
|
+
return Pair(base64, mediaType)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── view tree ─────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** Synchronously walk the view hierarchy from `root`. Safe to call
|
|
187
|
+
* from any thread *as long as no concurrent layout pass is
|
|
188
|
+
* invalidating subview lists* — at crash time the main thread is
|
|
189
|
+
* paused on the exception handler, so the read is race-free. */
|
|
190
|
+
private fun walkTree(root: View): Map<String, Any> {
|
|
191
|
+
val nodes = mutableMapOf<String, Any>()
|
|
192
|
+
var counter = 0
|
|
193
|
+
var nodeCount = 0
|
|
194
|
+
|
|
195
|
+
fun nextId(): String {
|
|
196
|
+
counter += 1
|
|
197
|
+
return "n$counter"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fun walk(view: View, depth: Int): String {
|
|
201
|
+
val id = nextId()
|
|
202
|
+
nodeCount += 1
|
|
203
|
+
val children = mutableListOf<String>()
|
|
204
|
+
if (depth < MAX_TREE_DEPTH && nodeCount < MAX_NODES && view is ViewGroup) {
|
|
205
|
+
for (i in 0 until view.childCount) {
|
|
206
|
+
if (nodeCount >= MAX_NODES) break
|
|
207
|
+
children.add(walk(view.getChildAt(i), depth + 1))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
val rect = "${view.left},${view.top},${view.width},${view.height}"
|
|
211
|
+
val propsSummary = mutableMapOf(
|
|
212
|
+
"frame" to rect,
|
|
213
|
+
"alpha" to String.format("%.2f", view.alpha),
|
|
214
|
+
"hidden" to (view.visibility != View.VISIBLE).toString(),
|
|
215
|
+
)
|
|
216
|
+
view.contentDescription?.toString()?.takeIf { it.isNotEmpty() }?.let {
|
|
217
|
+
propsSummary["contentDescription"] = if (it.length > 200) it.substring(0, 200) else it
|
|
218
|
+
}
|
|
219
|
+
nodes[id] = mapOf(
|
|
220
|
+
"type" to "View",
|
|
221
|
+
"name" to view.javaClass.simpleName,
|
|
222
|
+
"props_summary" to propsSummary,
|
|
223
|
+
"children" to children,
|
|
224
|
+
)
|
|
225
|
+
return id
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
val rootId = walk(root, 0)
|
|
229
|
+
return mapOf("rootId" to rootId, "nodes" to nodes)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── helpers for the crash-handler JSON path ───────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Convert a Kotlin Map-tree into a `JSONObject`-tree suitable for
|
|
236
|
+
* embedding inside the event JSON written by `SentoriCrashHandler`.
|
|
237
|
+
* Public so the crash handler can use it.
|
|
238
|
+
*/
|
|
239
|
+
@JvmStatic
|
|
240
|
+
fun toJson(value: Any?): Any {
|
|
241
|
+
return when (value) {
|
|
242
|
+
null -> JSONObject.NULL
|
|
243
|
+
is Map<*, *> -> JSONObject().apply {
|
|
244
|
+
for ((k, v) in value) put(k.toString(), toJson(v))
|
|
245
|
+
}
|
|
246
|
+
is List<*> -> JSONArray().apply {
|
|
247
|
+
for (v in value) put(toJson(v))
|
|
248
|
+
}
|
|
249
|
+
else -> value
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Convenience for `Canvas.draw` benchmarking in instrumentation
|
|
255
|
+
* tests (sub-F.10). Renders the input `view` onto a Bitmap on the
|
|
256
|
+
* caller's thread — *do not* use this at crash time; it only
|
|
257
|
+
* exists for test latency measurements.
|
|
258
|
+
*/
|
|
259
|
+
@JvmStatic
|
|
260
|
+
fun benchmarkRenderToBitmapBlocking(view: View): Long {
|
|
261
|
+
val w = view.width.coerceAtLeast(1)
|
|
262
|
+
val h = view.height.coerceAtLeast(1)
|
|
263
|
+
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
264
|
+
val canvas = Canvas(bmp)
|
|
265
|
+
val started = System.nanoTime()
|
|
266
|
+
view.draw(canvas)
|
|
267
|
+
val elapsed = System.nanoTime() - started
|
|
268
|
+
bmp.recycle()
|
|
269
|
+
return elapsed
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Phase 42 sub-F.10 — Robolectric coverage for SentoriScreenshotCapture.
|
|
2
|
+
//
|
|
3
|
+
// Run via Gradle on the Android host:
|
|
4
|
+
// ./gradlew :sentori-react-native:testDebugUnitTest
|
|
5
|
+
//
|
|
6
|
+
// We can't actually drive `PixelCopy.request` under Robolectric (it
|
|
7
|
+
// needs a real surface + EGL context). The instrumented version of
|
|
8
|
+
// this test runs against `androidTest` on a connected device /
|
|
9
|
+
// emulator — that's where the perf budget assertion (≤ 50 ms 95p)
|
|
10
|
+
// lives. The unit-test surface below exercises only the parts that
|
|
11
|
+
// don't need the GPU: the depth-capped view-tree walker and the
|
|
12
|
+
// `toJson` Map → JSONObject converter.
|
|
13
|
+
|
|
14
|
+
package com.sentori
|
|
15
|
+
|
|
16
|
+
import android.view.View
|
|
17
|
+
import androidx.test.core.app.ApplicationProvider
|
|
18
|
+
import org.json.JSONObject
|
|
19
|
+
import org.junit.Test
|
|
20
|
+
import org.junit.runner.RunWith
|
|
21
|
+
import org.robolectric.Robolectric
|
|
22
|
+
import org.robolectric.RobolectricTestRunner
|
|
23
|
+
import kotlin.test.assertEquals
|
|
24
|
+
import kotlin.test.assertNotNull
|
|
25
|
+
import kotlin.test.assertNull
|
|
26
|
+
import kotlin.test.assertTrue
|
|
27
|
+
|
|
28
|
+
@RunWith(RobolectricTestRunner::class)
|
|
29
|
+
class SentoriScreenshotCaptureTest {
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun toJsonConvertsNestedMapToJSONObject() {
|
|
33
|
+
val input = mapOf(
|
|
34
|
+
"rootId" to "n1",
|
|
35
|
+
"nodes" to mapOf(
|
|
36
|
+
"n1" to mapOf(
|
|
37
|
+
"type" to "View",
|
|
38
|
+
"children" to listOf("n2"),
|
|
39
|
+
),
|
|
40
|
+
"n2" to mapOf(
|
|
41
|
+
"type" to "Button",
|
|
42
|
+
"props_summary" to mapOf("alpha" to "1.00"),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
val json = SentoriScreenshotCapture.toJson(input)
|
|
47
|
+
assertTrue(json is JSONObject)
|
|
48
|
+
json as JSONObject
|
|
49
|
+
assertEquals("n1", json.getString("rootId"))
|
|
50
|
+
val n1 = json.getJSONObject("nodes").getJSONObject("n1")
|
|
51
|
+
assertEquals("View", n1.getString("type"))
|
|
52
|
+
assertEquals("n2", n1.getJSONArray("children").getString(0))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun captureKeyWindowReturnsNullWithoutRegisteredActivity() {
|
|
57
|
+
// Don't register an Application — the helper should fail
|
|
58
|
+
// gracefully (return null) instead of crashing.
|
|
59
|
+
val out = SentoriScreenshotCapture.captureKeyWindow()
|
|
60
|
+
// Either null (no last activity yet) or a map with viewTree
|
|
61
|
+
// (Robolectric may attach one transparently). Both are valid;
|
|
62
|
+
// the contract is "doesn't throw".
|
|
63
|
+
if (out != null) {
|
|
64
|
+
assertNotNull(out["viewTree"])
|
|
65
|
+
} else {
|
|
66
|
+
assertNull(out)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Test
|
|
71
|
+
fun toJsonHandlesNullAndPrimitives() {
|
|
72
|
+
assertEquals(JSONObject.NULL, SentoriScreenshotCapture.toJson(null))
|
|
73
|
+
assertEquals(42, SentoriScreenshotCapture.toJson(42))
|
|
74
|
+
assertEquals("hi", SentoriScreenshotCapture.toJson("hi"))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Test
|
|
78
|
+
fun benchmarkRenderReturnsPositiveDurationOnLiveView() {
|
|
79
|
+
// Build a minimal View hierarchy and time the benchmark
|
|
80
|
+
// helper — gives us a smoke signal that the API doesn't
|
|
81
|
+
// throw on synthesized views under Robolectric.
|
|
82
|
+
val activity = Robolectric.buildActivity(android.app.Activity::class.java)
|
|
83
|
+
.create()
|
|
84
|
+
.start()
|
|
85
|
+
.resume()
|
|
86
|
+
.get()
|
|
87
|
+
val v: View = activity.window.decorView
|
|
88
|
+
v.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
|
|
89
|
+
v.layout(0, 0, 200, 200)
|
|
90
|
+
val ns = SentoriScreenshotCapture.benchmarkRenderToBitmapBlocking(v)
|
|
91
|
+
assertTrue(ns > 0)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -68,7 +68,7 @@ import Foundation
|
|
|
68
68
|
let release = (cfg["release"] as? String) ?? "unknown"
|
|
69
69
|
let environment = (cfg["environment"] as? String) ?? "prod"
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
var event: [String: Any] = [
|
|
72
72
|
"id": UUID().uuidString.lowercased(),
|
|
73
73
|
"timestamp": iso8601(Date()),
|
|
74
74
|
"kind": "error",
|
|
@@ -95,6 +95,38 @@ import Foundation
|
|
|
95
95
|
"spanId": NSNull(),
|
|
96
96
|
]
|
|
97
97
|
|
|
98
|
+
// Phase 42 sub-E.04/07: capture the screen + view tree right
|
|
99
|
+
// before the app dies. The handler runs synchronously on the
|
|
100
|
+
// thread that threw NSException; UIKit's still valid at this
|
|
101
|
+
// point. Both blobs go in a temp `_pendingAttachments` field
|
|
102
|
+
// — JS strips it on next launch, uploads each via
|
|
103
|
+
// `POST /v1/events/<id>/attachments/<kind>`, then enqueues
|
|
104
|
+
// the cleaned event.
|
|
105
|
+
if let snap = SentoriScreenshotCapture.captureKeyWindow() {
|
|
106
|
+
var pending: [[String: Any]] = []
|
|
107
|
+
if let sc = snap["screenshot"] as? [String: Any],
|
|
108
|
+
let b64 = sc["base64"] as? String {
|
|
109
|
+
pending.append([
|
|
110
|
+
"kind": "screenshot",
|
|
111
|
+
"base64": b64,
|
|
112
|
+
"mediaType": (sc["mediaType"] as? String) ?? "image/jpeg",
|
|
113
|
+
"source": "ios",
|
|
114
|
+
])
|
|
115
|
+
}
|
|
116
|
+
if let vt = snap["viewTree"],
|
|
117
|
+
let data = try? JSONSerialization.data(withJSONObject: vt, options: []) {
|
|
118
|
+
pending.append([
|
|
119
|
+
"kind": "viewTree",
|
|
120
|
+
"base64": data.base64EncodedString(),
|
|
121
|
+
"mediaType": "application/json",
|
|
122
|
+
"source": "ios",
|
|
123
|
+
])
|
|
124
|
+
}
|
|
125
|
+
if !pending.isEmpty {
|
|
126
|
+
event["_pendingAttachments"] = pending
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
guard let dir = pendingDir() else { return }
|
|
99
131
|
let url = dir.appendingPathComponent("\(UUID().uuidString.lowercased()).json")
|
|
100
132
|
if let data = try? JSONSerialization.data(withJSONObject: event, options: []) {
|