@craft-native/android 0.0.175 → 0.0.177

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.
@@ -418,6 +418,7 @@ class CraftBridge(
418
418
  appReview: true,
419
419
  flashlight: {{ENABLE_CAMERA}} && ${hasFlashlight()},
420
420
  speech: true,
421
+ crashReporter: ${CraftCrashReporter.enabled},
421
422
  brightness: true,
422
423
  openURL: true,
423
424
  vibrationPattern: true,
@@ -1748,6 +1749,26 @@ class CraftBridge(
1748
1749
  return Promise.resolve().then(function() { CraftAndroid.stopSpeaking(); });
1749
1750
  }
1750
1751
  };
1752
+ // Crash and error reporting (CraftCrashReporter.kt): the page's
1753
+ // error SDK hands its events here, so they share the app's
1754
+ // on-disk outbox, its session and the device's own context.
1755
+ craft.crashReporter = {
1756
+ capture: function(event) {
1757
+ return Promise.resolve().then(function() { return CraftAndroid.crashReporterCapture(JSON.stringify(event || {})); });
1758
+ },
1759
+ setUser: function(id) {
1760
+ return Promise.resolve().then(function() { return CraftAndroid.crashReporterSetUser(id == null ? '' : String(id)); });
1761
+ },
1762
+ addBreadcrumb: function(breadcrumb) {
1763
+ return Promise.resolve().then(function() { return CraftAndroid.crashReporterAddBreadcrumb(JSON.stringify(breadcrumb || {})); });
1764
+ },
1765
+ flush: function() {
1766
+ return Promise.resolve().then(function() { return CraftAndroid.crashReporterFlush(); });
1767
+ },
1768
+ context: function() {
1769
+ return Promise.resolve().then(function() { return JSON.parse(CraftAndroid.crashReporterContext()); });
1770
+ }
1771
+ };
1751
1772
  craft.location = {
1752
1773
  getCurrentPosition: craft.getCurrentPosition,
1753
1774
  watchPosition: craft.watchPosition,
@@ -1859,6 +1880,36 @@ class CraftBridge(
1859
1880
  return true
1860
1881
  }
1861
1882
 
1883
+ // ==================== Crash reporting ====================
1884
+
1885
+ /** Queues an event the page built; false when reporting is off or the event has no message. */
1886
+ @JavascriptInterface
1887
+ fun crashReporterCapture(eventJson: String): Boolean =
1888
+ runCatching { CraftCrashReporter.capture(JSONObject(eventJson)) }.getOrDefault(false)
1889
+
1890
+ @JavascriptInterface
1891
+ fun crashReporterSetUser(id: String): Boolean {
1892
+ CraftCrashReporter.setUser(id)
1893
+ return CraftCrashReporter.enabled
1894
+ }
1895
+
1896
+ @JavascriptInterface
1897
+ fun crashReporterAddBreadcrumb(breadcrumbJson: String): Boolean {
1898
+ val crumb = runCatching { JSONObject(breadcrumbJson) }.getOrNull() ?: return false
1899
+ CraftCrashReporter.addBreadcrumb(crumb)
1900
+ return CraftCrashReporter.enabled
1901
+ }
1902
+
1903
+ @JavascriptInterface
1904
+ fun crashReporterFlush(): Boolean {
1905
+ CraftCrashReporter.requestFlush()
1906
+ return CraftCrashReporter.enabled
1907
+ }
1908
+
1909
+ @JavascriptInterface
1910
+ fun crashReporterContext(): String =
1911
+ if (CraftCrashReporter.enabled) CraftCrashReporter.context().toString() else JSONObject().put("enabled", false).toString()
1912
+
1862
1913
  // ==================== Speech Recognition ====================
1863
1914
 
1864
1915
  /**
@@ -0,0 +1,544 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.app.ActivityManager
4
+ import android.app.ApplicationExitInfo
5
+ import android.content.ContentProvider
6
+ import android.content.ContentValues
7
+ import android.content.Context
8
+ import android.database.Cursor
9
+ import android.net.ConnectivityManager
10
+ import android.net.Network
11
+ import android.net.Uri
12
+ import android.os.Build
13
+ import android.os.Handler
14
+ import android.os.HandlerThread
15
+ import android.os.Process
16
+ import android.util.Log
17
+ import org.json.JSONArray
18
+ import org.json.JSONObject
19
+ import java.io.File
20
+ import java.net.HttpURLConnection
21
+ import java.net.URL
22
+ import java.text.SimpleDateFormat
23
+ import java.util.Date
24
+ import java.util.Locale
25
+ import java.util.TimeZone
26
+ import java.util.UUID
27
+
28
+ /**
29
+ * Crash and error reporting to bughq (https://bughq.org, or a self-hosted
30
+ * host), on when craft.config.json has `crashReporting` with a project key.
31
+ *
32
+ * What it catches:
33
+ * - An uncaught JVM exception on any thread. The handler builds the event and
34
+ * writes it to the outbox before the process dies, then hands the crash on
35
+ * to the handler that was there before (Android's, which shows the dialog).
36
+ * - An ANR, or a crash in native code (libcraft.so), through
37
+ * ApplicationExitInfo on the next launch (Android 11+). The system records
38
+ * why the last process ended, and for an ANR it keeps every thread's stack.
39
+ * - Uncaught JavaScript errors in native screens and the app script, and what
40
+ * the page or a screen hands over through `craft.crashReporter.capture`.
41
+ *
42
+ * Events wait in an on-disk outbox and are sent when the device is online, so
43
+ * a crash captured on the underground arrives once it is back above ground.
44
+ * Release builds are minified by R8; their frames stay obfuscated here and
45
+ * bughq retraces them with the build's mapping.txt (uploaded by the build).
46
+ *
47
+ * Only the app's own user id (`craft.crashReporter.setUser`) identifies a
48
+ * person. Nothing else about them is read or sent.
49
+ */
50
+ object CraftCrashReporter {
51
+ private const val TAG = "CraftCrashReporter"
52
+ const val SDK_NAME = "bughq.craft.android"
53
+ private const val MAX_OUTBOX = 200
54
+ private const val MAX_AGE_MS = 14L * 86_400_000L
55
+
56
+ @Volatile var enabled = false
57
+ private set
58
+ private lateinit var context: Context
59
+ private lateinit var directory: File
60
+ private lateinit var outbox: File
61
+ private var key = ""
62
+ private var host = "https://bughq.org"
63
+ private var environment = "production"
64
+ private var releaseOverride: String? = null
65
+ private var sendSessions = true
66
+ private val thread = HandlerThread("craft-crash-reporter").apply { start() }
67
+ private val handler = Handler(thread.looper)
68
+ private var flushing = false
69
+ private var retryAt = 0L
70
+ private var failures = 0
71
+ private val breadcrumbs = ArrayList<JSONObject>()
72
+ private var breadcrumbsDirty = false
73
+ @Volatile private var userId: String? = null
74
+ private var lastJsError: Pair<String, Long>? = null
75
+
76
+ val sessionId: String = UUID.randomUUID().toString()
77
+ private val sessionStart = System.currentTimeMillis()
78
+
79
+ /** Reads craft.config.json and installs everything. Safe to call more than once. */
80
+ @Synchronized
81
+ fun start(context: Context) {
82
+ if (enabled) return
83
+ val config = runCatching {
84
+ context.assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
85
+ }.getOrNull() ?: return
86
+ val settings = config.optJSONObject("crashReporting") ?: return
87
+ val projectKey = settings.optString("key")
88
+ if (projectKey.isBlank()) return
89
+ this.context = context.applicationContext
90
+ key = projectKey
91
+ host = settings.optString("host").ifBlank { "https://bughq.org" }.trimEnd('/')
92
+ environment = settings.optString("environment").ifBlank { "production" }
93
+ releaseOverride = settings.optString("release").ifBlank { null }
94
+ sendSessions = !(settings.has("sessions") && !settings.optBoolean("sessions", true))
95
+ directory = File(this.context.filesDir, "craft-crash").apply { mkdirs() }
96
+ outbox = File(directory, "outbox").apply { mkdirs() }
97
+ userId = prefs().getString("user", null)
98
+ enabled = true
99
+
100
+ installUncaughtHandler()
101
+ handler.post {
102
+ // The last process's story first: it names a session and a trail
103
+ // this launch is about to replace.
104
+ collectExitReasons()
105
+ recordSession()
106
+ pruneOutbox()
107
+ if (sendSessions) enqueueSession("ok")
108
+ flush()
109
+ }
110
+ watchNetwork()
111
+ }
112
+
113
+ private fun prefs() = context.getSharedPreferences("craft-crash", Context.MODE_PRIVATE)
114
+
115
+ // MARK: Versions
116
+
117
+ private fun versions(): Pair<String, String> = runCatching {
118
+ val info = context.packageManager.getPackageInfo(context.packageName, 0)
119
+ val build = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode.toString() else @Suppress("DEPRECATION") info.versionCode.toString()
120
+ (info.versionName ?: "") to build
121
+ }.getOrDefault("" to "")
122
+
123
+ val release: String
124
+ get() = releaseOverride ?: versions().let { (version, build) -> if (build.isBlank()) version else "$version+$build" }
125
+
126
+ // MARK: The uncaught-exception handler
127
+
128
+ private fun installUncaughtHandler() {
129
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
130
+ Thread.setDefaultUncaughtExceptionHandler { failed, error ->
131
+ try {
132
+ val event = baseEvent(release, sessionId, sessionStart)
133
+ .put("type", error.javaClass.name)
134
+ .put("message", error.message ?: error.javaClass.simpleName)
135
+ .put("level", "fatal")
136
+ .put("exception", exceptionJson(error, "uncaught", handled = false))
137
+ .put("breadcrumbs", JSONArray(synchronized(breadcrumbs) { breadcrumbs.takeLast(50) }))
138
+ .put("tags", JSONObject().put("mechanism", "uncaught").put("thread", failed.name))
139
+ // Written on this thread, now: the process ends when this returns.
140
+ writeOutbox("errors", event)
141
+ prefs().edit().putString("crashed.session", sessionId).commit()
142
+ } catch (_: Throwable) {
143
+ // Reporting must never be the reason a crash report is lost.
144
+ }
145
+ previous?.uncaughtException(failed, error)
146
+ }
147
+ }
148
+
149
+ /** A Throwable and its causes as the wire `exception` block. */
150
+ fun exceptionJson(error: Throwable, mechanism: String, handled: Boolean): JSONObject {
151
+ val result = JSONObject()
152
+ .put("mechanism", mechanism)
153
+ .put("handled", handled)
154
+ .put("frames", framesJson(error.stackTrace))
155
+ val causes = JSONArray()
156
+ var cause = error.cause
157
+ var depth = 0
158
+ while (cause != null && cause !== error && depth < 5) {
159
+ causes.put(JSONObject()
160
+ .put("type", cause.javaClass.name)
161
+ .put("value", cause.message ?: "")
162
+ .put("frames", framesJson(cause.stackTrace)))
163
+ cause = cause.cause
164
+ depth += 1
165
+ }
166
+ if (causes.length() > 0) result.put("causes", causes)
167
+ return result
168
+ }
169
+
170
+ private fun framesJson(trace: Array<StackTraceElement>): JSONArray {
171
+ val frames = JSONArray()
172
+ for (element in trace.take(128)) {
173
+ val frame = JSONObject()
174
+ .put("module", element.className)
175
+ .put("function", element.methodName)
176
+ .put("in_app", inApp(element.className))
177
+ element.fileName?.let { frame.put("file", it) }
178
+ if (element.lineNumber > 0) frame.put("line", element.lineNumber)
179
+ frames.put(frame)
180
+ }
181
+ return frames
182
+ }
183
+
184
+ /**
185
+ * The app's own frames. R8 renames the app's classes to short names in
186
+ * no package, which is why "not a known system package" is the rule here;
187
+ * bughq judges again after it retraces.
188
+ */
189
+ private fun inApp(className: String): Boolean {
190
+ if (className.startsWith(context.packageName)) return true
191
+ val system = listOf("java.", "javax.", "kotlin.", "kotlinx.", "android.", "androidx.", "dalvik.", "libcore.", "com.android.", "com.google.", "org.json.", "sun.", "okhttp3.", "okio.")
192
+ return system.none { className.startsWith(it) }
193
+ }
194
+
195
+ // MARK: ApplicationExitInfo (ANR, native crash)
196
+
197
+ private fun collectExitReasons() {
198
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
199
+ val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return
200
+ val reasons = runCatching { manager.getHistoricalProcessExitReasons(context.packageName, 0, 16) }.getOrNull() ?: return
201
+ val seen = prefs().getLong("exit.seen", 0L)
202
+ val sessions = JSONObject(prefs().getString("pid.sessions", "{}") ?: "{}")
203
+ var newest = seen
204
+ for (info in reasons) {
205
+ if (info.timestamp <= seen) continue
206
+ newest = maxOf(newest, info.timestamp)
207
+ val session = sessions.optJSONObject(info.pid.toString())
208
+ when (info.reason) {
209
+ ApplicationExitInfo.REASON_ANR -> reportExit(info, session, "ApplicationNotResponding", "The app stopped responding" + (info.description?.let { ": $it" } ?: ""), "anr", "abnormal")
210
+ ApplicationExitInfo.REASON_CRASH_NATIVE -> reportExit(info, session, "NativeCrash", info.description ?: "The app crashed in native code", "native", "crashed")
211
+ // REASON_CRASH (JVM) is the uncaught handler's, already in the outbox.
212
+ }
213
+ }
214
+ if (newest > seen) prefs().edit().putLong("exit.seen", newest).apply()
215
+ }
216
+
217
+ private fun reportExit(info: ApplicationExitInfo, session: JSONObject?, type: String, message: String, mechanism: String, status: String) {
218
+ val trace = runCatching { info.traceInputStream?.bufferedReader()?.use { it.readText() } }.getOrNull()
219
+ val frames = if (mechanism == "anr" && trace != null) mainThreadFrames(trace) else JSONArray()
220
+ val release = session?.optString("release")?.ifBlank { null } ?: release
221
+ val event = baseEvent(release, session?.optString("id")?.ifBlank { null }, session?.optLong("startedAt"))
222
+ .put("type", type)
223
+ .put("message", message)
224
+ .put("level", if (status == "crashed") "fatal" else "error")
225
+ .put("timestamp", iso(info.timestamp))
226
+ .put("tags", JSONObject().put("mechanism", mechanism).put("importance", info.importance.toString()))
227
+ if (frames.length() > 0) event.put("exception", JSONObject().put("mechanism", mechanism).put("handled", false).put("frames", frames))
228
+ writeOutbox("errors", event)
229
+ // An ANR the person had to close ends its session abnormally: say so,
230
+ // in case no crash event marks it.
231
+ val sessionId = session?.optString("id")
232
+ if (sendSessions && !sessionId.isNullOrBlank()) {
233
+ writeOutbox("sessions", JSONObject().put("sessions", JSONArray().put(JSONObject()
234
+ .put("id", sessionId)
235
+ .put("release", release)
236
+ .put("environment", environment)
237
+ .put("platform", "android")
238
+ .put("status", status)
239
+ .put("started_at", iso(session.optLong("startedAt", info.timestamp))))))
240
+ }
241
+ }
242
+
243
+ /** The main thread's frames from an ANR trace (`"main" prio=5 …` then ` at a.b.c(File.kt:12)`). */
244
+ fun mainThreadFrames(trace: String): JSONArray {
245
+ val frames = JSONArray()
246
+ val lines = trace.lines()
247
+ val start = lines.indexOfFirst { it.startsWith("\"main\"") }
248
+ if (start < 0) return frames
249
+ val frame = Regex("""^\s+at ([\w$.<>]+)\.([\w$<>-]+)\((?:([^:)]+)(?::(\d+))?|[^)]*)\)""")
250
+ for (line in lines.drop(start + 1)) {
251
+ if (line.isBlank() || line.startsWith("\"")) break
252
+ val match = frame.find(line) ?: continue
253
+ val (module, method, file, number) = match.destructured
254
+ val json = JSONObject().put("module", module).put("function", method).put("in_app", inApp(module))
255
+ if (file.isNotBlank()) json.put("file", file)
256
+ number.toIntOrNull()?.let { json.put("line", it) }
257
+ frames.put(json)
258
+ if (frames.length() >= 128) break
259
+ }
260
+ return frames
261
+ }
262
+
263
+ // MARK: Session
264
+
265
+ private fun recordSession() {
266
+ val (version, build) = versions()
267
+ val pidSessions = JSONObject(prefs().getString("pid.sessions", "{}") ?: "{}")
268
+ pidSessions.put(Process.myPid().toString(), JSONObject()
269
+ .put("id", sessionId)
270
+ .put("startedAt", sessionStart)
271
+ .put("release", release)
272
+ .put("version", version)
273
+ .put("build", build))
274
+ // Only the last few processes matter for the next exit lookup.
275
+ val keys = pidSessions.keys().asSequence().toList()
276
+ if (keys.size > 8) keys.sortedBy { pidSessions.optJSONObject(it)?.optLong("startedAt") ?: 0L }.take(keys.size - 8).forEach { pidSessions.remove(it) }
277
+ prefs().edit().putString("pid.sessions", pidSessions.toString()).apply()
278
+ }
279
+
280
+ private fun enqueueSession(status: String) {
281
+ val session = JSONObject()
282
+ .put("id", sessionId)
283
+ .put("release", release)
284
+ .put("environment", environment)
285
+ .put("platform", "android")
286
+ .put("status", status)
287
+ .put("started_at", iso(sessionStart))
288
+ userId?.let { session.put("user_id", it) }
289
+ writeOutbox("sessions", JSONObject().put("sessions", JSONArray().put(session)))
290
+ }
291
+
292
+ // MARK: JavaScript and the page
293
+
294
+ /** An uncaught error in a native screen or the app script's JavaScript. */
295
+ fun captureJavaScript(name: String?, message: String, stack: String?, route: String, isAppScript: Boolean) {
296
+ if (!enabled) return
297
+ handler.post {
298
+ val dedupe = "$name|$message|${stack?.take(200)}"
299
+ val last = lastJsError
300
+ if (last != null && last.first == dedupe && System.currentTimeMillis() - last.second < 5_000) return@post
301
+ lastJsError = dedupe to System.currentTimeMillis()
302
+ val event = baseEvent(release, sessionId, sessionStart)
303
+ .put("type", name?.ifBlank { null } ?: "Error")
304
+ .put("message", message)
305
+ .put("level", "error")
306
+ .put("url", "app://$route")
307
+ .put("framework", "stx-native")
308
+ .put("breadcrumbs", JSONArray(synchronized(breadcrumbs) { breadcrumbs.takeLast(50) }))
309
+ .put("tags", JSONObject().put("mechanism", "javascript").put("runtime", if (isAppScript) "app-script" else "screen").put("route", route))
310
+ if (!stack.isNullOrBlank()) event.put("stack", stack)
311
+ writeOutbox("errors", event)
312
+ flush()
313
+ }
314
+ }
315
+
316
+ /** An event the page or a screen built (with @bughq/sdk) and handed over. */
317
+ fun capture(given: JSONObject): Boolean {
318
+ if (!enabled || !given.has("message")) return false
319
+ handler.post {
320
+ val event = baseEvent(release, sessionId, sessionStart)
321
+ val own = event.optJSONObject("contexts") ?: JSONObject()
322
+ for (name in given.keys()) {
323
+ when (name) {
324
+ "key", "project", "session", "release", "environment", "os", "platform" -> {}
325
+ "contexts" -> {
326
+ val contexts = given.optJSONObject(name) ?: JSONObject()
327
+ for (ownName in own.keys()) contexts.put(ownName, own.get(ownName))
328
+ event.put("contexts", contexts)
329
+ }
330
+ "user" -> if (userId == null) event.put("user", given.get(name))
331
+ else -> event.put(name, given.get(name))
332
+ }
333
+ }
334
+ if (!event.has("type")) event.put("type", "Error")
335
+ writeOutbox("errors", event)
336
+ flush()
337
+ }
338
+ return true
339
+ }
340
+
341
+ fun setUser(id: String?) {
342
+ val value = id?.takeIf { it.isNotBlank() }?.take(128)
343
+ userId = value
344
+ if (enabled) prefs().edit().putString("user", value).apply()
345
+ }
346
+
347
+ fun addBreadcrumb(crumb: JSONObject) {
348
+ if (!enabled) return
349
+ if (!crumb.has("timestamp")) crumb.put("timestamp", iso(System.currentTimeMillis()))
350
+ synchronized(breadcrumbs) {
351
+ breadcrumbs.add(crumb)
352
+ while (breadcrumbs.size > 100) breadcrumbs.removeAt(0)
353
+ }
354
+ if (!breadcrumbsDirty) {
355
+ breadcrumbsDirty = true
356
+ handler.postDelayed({ saveBreadcrumbs() }, 1_000)
357
+ }
358
+ }
359
+
360
+ fun screenAppeared(route: String) {
361
+ addBreadcrumb(JSONObject().put("type", "navigation").put("category", "navigation").put("message", route).put("data", JSONObject().put("to", route)))
362
+ }
363
+
364
+ fun requestFailed(method: String, url: String, status: Int?, error: String?) {
365
+ val data = JSONObject().put("method", method).put("url", maskUrl(url))
366
+ status?.let { data.put("status", it) }
367
+ error?.let { data.put("error", it) }
368
+ addBreadcrumb(JSONObject().put("type", "http").put("category", "fetch").put("level", if (status == null || status >= 500) "error" else "warning").put("data", data))
369
+ }
370
+
371
+ fun requestFlush() {
372
+ if (enabled) handler.post { flush() }
373
+ }
374
+
375
+ fun context(): JSONObject = baseEvent(release, sessionId, sessionStart).put("enabled", enabled)
376
+
377
+ private fun saveBreadcrumbs() {
378
+ if (!enabled) return
379
+ breadcrumbsDirty = false
380
+ val copy = synchronized(breadcrumbs) { JSONArray(breadcrumbs) }
381
+ runCatching { File(directory, "breadcrumbs.json").writeText(copy.toString()) }
382
+ }
383
+
384
+ fun maskUrl(raw: String): String = runCatching {
385
+ val uri = Uri.parse(raw)
386
+ val path = (uri.path ?: "").split('/').joinToString("/") { segment ->
387
+ when {
388
+ segment.isEmpty() -> segment
389
+ segment.all { it.isDigit() } -> "{n}"
390
+ segment.length >= 4 && segment.any { it.isLetter() } && segment.any { it.isDigit() } -> "{id}"
391
+ else -> segment
392
+ }
393
+ }
394
+ "${uri.host ?: ""}$path${if (uri.query != null) "?…" else ""}"
395
+ }.getOrDefault(raw.substringBefore('?').take(200))
396
+
397
+ // MARK: Events
398
+
399
+ private fun baseEvent(release: String, session: String?, startedAt: Long?): JSONObject {
400
+ val (version, build) = versions()
401
+ val event = JSONObject()
402
+ .put("platform", "android")
403
+ .put("release", release)
404
+ .put("environment", environment)
405
+ .put("os", "Android ${Build.VERSION.RELEASE}")
406
+ .put("framework", "craft")
407
+ .put("timestamp", iso(System.currentTimeMillis()))
408
+ .put("sdk", JSONObject().put("name", SDK_NAME).put("version", "{{CRAFT_VERSION}}"))
409
+ .put("contexts", JSONObject()
410
+ .put("app", JSONObject().put("id", context.packageName).put("version", version).put("build", build))
411
+ .put("device", JSONObject()
412
+ .put("model", Build.MODEL)
413
+ .put("manufacturer", Build.MANUFACTURER)
414
+ .put("emulator", Build.FINGERPRINT.contains("generic") || Build.PRODUCT.contains("sdk"))
415
+ .put("locale", Locale.getDefault().toLanguageTag()))
416
+ .put("os", JSONObject().put("name", "Android").put("version", Build.VERSION.RELEASE).put("api", Build.VERSION.SDK_INT)))
417
+ if (session != null) event.put("session", JSONObject().put("id", session).put("startedAt", iso(startedAt ?: sessionStart)))
418
+ userId?.let { event.put("user", JSONObject().put("id", it)) }
419
+ return event
420
+ }
421
+
422
+ private fun writeOutbox(endpoint: String, body: JSONObject) {
423
+ val item = JSONObject().put("endpoint", endpoint).put("body", body)
424
+ val name = String.format(Locale.ROOT, "%013d-%s.json", System.currentTimeMillis(), UUID.randomUUID())
425
+ runCatching {
426
+ val temp = File(outbox, "$name.tmp")
427
+ temp.writeText(item.toString())
428
+ temp.renameTo(File(outbox, name))
429
+ }.onFailure { Log.w(TAG, "could not queue an event", it) }
430
+ }
431
+
432
+ private fun pruneOutbox() {
433
+ val files = outbox.listFiles { file -> file.name.endsWith(".json") }?.sortedBy { it.name } ?: return
434
+ val cutoff = System.currentTimeMillis() - MAX_AGE_MS
435
+ files.forEachIndexed { index, file ->
436
+ val stamp = file.name.take(13).toLongOrNull() ?: 0L
437
+ if (index < files.size - MAX_OUTBOX || stamp < cutoff) file.delete()
438
+ }
439
+ }
440
+
441
+ // MARK: Delivery
442
+
443
+ private fun watchNetwork() {
444
+ val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
445
+ runCatching {
446
+ manager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
447
+ override fun onAvailable(network: Network) {
448
+ // Back online: what waited goes now.
449
+ handler.post {
450
+ retryAt = 0L
451
+ failures = 0
452
+ flush()
453
+ }
454
+ }
455
+ })
456
+ }
457
+ }
458
+
459
+ /** Sends the outbox, oldest first, one at a time, on the reporter's thread. */
460
+ private fun flush() {
461
+ if (!enabled || flushing || System.currentTimeMillis() < retryAt) return
462
+ val next = outbox.listFiles { file -> file.name.endsWith(".json") }?.minByOrNull { it.name } ?: return
463
+ val item = runCatching { JSONObject(next.readText()) }.getOrNull()
464
+ val endpoint = item?.optString("endpoint")
465
+ val body = item?.optJSONObject("body")
466
+ if (item == null || body == null || endpoint.isNullOrBlank()) {
467
+ next.delete()
468
+ return flush()
469
+ }
470
+ body.put("key", key)
471
+ flushing = true
472
+ val status = runCatching {
473
+ val connection = URL("$host/${if (endpoint == "sessions") "sessions" else "errors"}").openConnection() as HttpURLConnection
474
+ connection.requestMethod = "POST"
475
+ connection.connectTimeout = 15_000
476
+ connection.readTimeout = 20_000
477
+ connection.doOutput = true
478
+ connection.setRequestProperty("Content-Type", "application/json")
479
+ connection.setRequestProperty("X-BugHQ-Key", key)
480
+ connection.outputStream.use { it.write(body.toString().toByteArray()) }
481
+ val code = connection.responseCode
482
+ connection.disconnect()
483
+ code
484
+ }.getOrDefault(0)
485
+ flushing = false
486
+ if (status in 200..299 || (status in 400..499 && status != 408 && status != 429)) {
487
+ // Delivered, or refused for good: a 4xx will not change on retry.
488
+ next.delete()
489
+ failures = 0
490
+ handler.post { flush() }
491
+ } else {
492
+ failures += 1
493
+ val delay = minOf(3_600_000L, 15_000L shl minOf(failures, 8))
494
+ retryAt = System.currentTimeMillis() + delay
495
+ handler.postDelayed({ flush() }, delay)
496
+ }
497
+ }
498
+
499
+ private fun iso(millis: Long): String {
500
+ val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT)
501
+ format.timeZone = TimeZone.getTimeZone("UTC")
502
+ return format.format(Date(millis))
503
+ }
504
+
505
+ // MARK: Debug-only test crash
506
+
507
+ /**
508
+ * `adb shell am start -n <package>/.MainActivity --es craftCrashTest native|js`
509
+ * crashes the app (or throws in the app script) a few seconds later, to
510
+ * check the pipeline end to end. `BuildConfig.DEBUG` is a constant false
511
+ * in a release build, so R8 removes this code from it entirely.
512
+ */
513
+ /** Called by the activity with its launch intent's `craftCrashTest` extra (debug builds only). */
514
+ fun debugCrashTestRequested(kind: String?, throwInAppScript: (String) -> Unit) {
515
+ if (!BuildConfig.DEBUG || kind.isNullOrBlank() || !enabled) return
516
+ Log.w(TAG, "crash test $kind scheduled")
517
+ Handler(context.mainLooper).postDelayed({
518
+ addBreadcrumb(JSONObject().put("type", "debug").put("category", "crash-test").put("message", "Crash test: $kind"))
519
+ saveBreadcrumbs()
520
+ when (kind) {
521
+ "js" -> throwInAppScript("Craft crash reporter test error")
522
+ else -> throw IllegalStateException("Craft crash reporter test crash")
523
+ }
524
+ }, 4_000)
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Starts the crash reporter before anything else in the process: a content
530
+ * provider is created before Application.onCreate, for an activity, a push or
531
+ * a background refresh alike, which is the earliest an app can run code
532
+ * without an Application subclass.
533
+ */
534
+ class CraftCrashReporterInit : ContentProvider() {
535
+ override fun onCreate(): Boolean {
536
+ context?.let { runCatching { CraftCrashReporter.start(it) } }
537
+ return true
538
+ }
539
+ override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
540
+ override fun getType(uri: Uri): String? = null
541
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
542
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
543
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
544
+ }
@@ -335,6 +335,12 @@ object CraftNative {
335
335
  margins: FloatArray,
336
336
  flags: FloatArray,
337
337
  ): FloatArray?
338
+ private external fun nativeLayoutTree(
339
+ width: Float,
340
+ height: Float,
341
+ nodeData: FloatArray,
342
+ treeMeta: FloatArray,
343
+ ): FloatArray?
338
344
  private external fun nativeClipboardRead(activity: Activity): String?
339
345
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
340
346
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
@@ -584,6 +590,17 @@ object CraftNative {
584
590
  }
585
591
  }
586
592
 
593
+ /** Resolve a guarded direct-child tree in Zig, or null for Kotlin fallback. */
594
+ fun layoutTree(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
595
+ if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
596
+ return try {
597
+ nativeLayoutTree(width, height, nodeData, treeMeta)
598
+ ?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
599
+ } catch (e: UnsatisfiedLinkError) {
600
+ null
601
+ }
602
+ }
603
+
587
604
  /**
588
605
  * The clipboard's text, or null to use the Kotlin implementation.
589
606
  *