@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.
Files changed (46) hide show
  1. package/android/src/main/java/com/sentori/SentoriAnrWatchdog.kt +46 -0
  2. package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +53 -0
  3. package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +271 -0
  4. package/android/src/test/java/com/sentori/SentoriScreenshotCaptureTest.kt +93 -0
  5. package/ios/SentoriCrashHandler.swift +33 -1
  6. package/ios/SentoriScreenshotCapture.swift +169 -0
  7. package/ios/Tests/SentoriScreenshotCaptureTests.swift +59 -0
  8. package/lib/capture.d.ts +6 -0
  9. package/lib/capture.d.ts.map +1 -1
  10. package/lib/capture.js +65 -9
  11. package/lib/capture.js.map +1 -1
  12. package/lib/config.d.ts +2 -0
  13. package/lib/config.d.ts.map +1 -1
  14. package/lib/config.js.map +1 -1
  15. package/lib/handlers/screenshot.d.ts +12 -0
  16. package/lib/handlers/screenshot.d.ts.map +1 -0
  17. package/lib/handlers/screenshot.js +85 -0
  18. package/lib/handlers/screenshot.js.map +1 -0
  19. package/lib/index.d.ts +5 -0
  20. package/lib/index.d.ts.map +1 -1
  21. package/lib/index.js +5 -0
  22. package/lib/index.js.map +1 -1
  23. package/lib/init.d.ts +7 -0
  24. package/lib/init.d.ts.map +1 -1
  25. package/lib/init.js +21 -3
  26. package/lib/init.js.map +1 -1
  27. package/lib/mask.d.ts +30 -0
  28. package/lib/mask.d.ts.map +1 -0
  29. package/lib/mask.js +77 -0
  30. package/lib/mask.js.map +1 -0
  31. package/lib/transport.d.ts +22 -0
  32. package/lib/transport.d.ts.map +1 -1
  33. package/lib/transport.js +62 -0
  34. package/lib/transport.js.map +1 -1
  35. package/lib/types.d.ts +1 -1
  36. package/lib/types.d.ts.map +1 -1
  37. package/package.json +9 -4
  38. package/src/__tests__/screenshot.test.ts +88 -0
  39. package/src/capture.ts +79 -9
  40. package/src/config.ts +2 -0
  41. package/src/handlers/screenshot.ts +115 -0
  42. package/src/index.ts +5 -0
  43. package/src/init.ts +55 -4
  44. package/src/mask.tsx +95 -0
  45. package/src/transport.ts +77 -0
  46. 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
- let event: [String: Any] = [
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: []) {