craft-native 0.0.175 → 0.0.176

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 (32) hide show
  1. package/dist/android/src/index.d.ts +19 -0
  2. package/dist/android/src/index.js +19 -2
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +51 -0
  5. package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
  6. package/dist/android/templates/CraftNative.kt.template +17 -0
  7. package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
  8. package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
  9. package/dist/android/templates/CraftNativeServices.kt.template +31 -1
  10. package/dist/android/templates/MainActivityNative.kt.template +986 -304
  11. package/dist/android/templates/build.gradle.kts.app.template +4 -2
  12. package/dist/api/mobile.d.ts +31 -0
  13. package/dist/cli.js +1 -1
  14. package/dist/components/index.d.ts +203 -2
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/src/index.d.ts +19 -0
  20. package/dist/ios/src/index.js +7 -1
  21. package/dist/ios/templates/CraftApp.swift +51 -1
  22. package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
  23. package/dist/ios/templates/CraftHybrid.swift +10 -0
  24. package/dist/ios/templates/CraftNativeActions.swift +49 -1
  25. package/dist/ios/templates/CraftNativeFlatList.swift +13 -21
  26. package/dist/ios/templates/CraftNativeMutation.swift +1 -1
  27. package/dist/ios/templates/CraftNativeScreen.swift +393 -55
  28. package/dist/ios/templates/project.yml.template +1 -0
  29. package/dist/macos/templates/CraftNativeMutation.swift +1 -1
  30. package/dist/macos/templates/native_layout.zig +389 -17
  31. package/dist/mobile.js +66 -0
  32. package/package.json +1 -1
@@ -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
  *
@@ -49,8 +49,19 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
49
49
  holder.host.layoutParams = holderLayoutParams()
50
50
  val view = renderItem?.invoke(item.node, item.identity, holder.hostedView) ?: View(holder.host.context)
51
51
  val renderedLayout = view.layoutParams
52
+ // iOS pins every row root to the collection cell's content width.
53
+ // A freshly-rendered Android root normally carries WRAP_CONTENT,
54
+ // which otherwise shrinks a vertical row to its text width and
55
+ // makes the two hosts disagree on hit/background geometry. Keep an
56
+ // authored width when one exists; only the default is promoted.
57
+ val renderedWidth = renderedLayout?.width ?: LayoutParams.WRAP_CONTENT
58
+ val hostedWidth = if (!horizontal && renderedWidth == LayoutParams.WRAP_CONTENT) {
59
+ LayoutParams.MATCH_PARENT
60
+ } else {
61
+ renderedWidth
62
+ }
52
63
  val hostedLayout = FrameLayout.LayoutParams(
53
- renderedLayout?.width ?: LayoutParams.MATCH_PARENT,
64
+ hostedWidth,
54
65
  renderedLayout?.height ?: LayoutParams.WRAP_CONTENT,
55
66
  )
56
67
  if (renderedLayout is ViewGroup.MarginLayoutParams) {
@@ -202,14 +213,14 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
202
213
  }
203
214
 
204
215
  fun setContentContainerStyle(style: JSONObject?, density: Float) {
205
- val padding = (style?.optDouble("padding", 0.0) ?: 0.0) * density
206
- val horizontal = (style?.optDouble("paddingHorizontal", padding / density) ?: (padding / density)) * density
207
- val vertical = (style?.optDouble("paddingVertical", padding / density) ?: (padding / density)) * density
216
+ val padding = craftNativePadding(style, "padding", 0, density)
217
+ val horizontal = craftNativePadding(style, "paddingHorizontal", padding, density)
218
+ val vertical = craftNativePadding(style, "paddingVertical", padding, density)
208
219
  setPadding(
209
- ((style?.optDouble("paddingLeft", horizontal / density) ?: (horizontal / density)) * density).toInt(),
210
- ((style?.optDouble("paddingTop", vertical / density) ?: (vertical / density)) * density).toInt(),
211
- ((style?.optDouble("paddingRight", horizontal / density) ?: (horizontal / density)) * density).toInt(),
212
- ((style?.optDouble("paddingBottom", vertical / density) ?: (vertical / density)) * density).toInt(),
220
+ craftNativePadding(style, "paddingLeft", horizontal, density),
221
+ craftNativePadding(style, "paddingTop", vertical, density),
222
+ craftNativePadding(style, "paddingRight", horizontal, density),
223
+ craftNativePadding(style, "paddingBottom", vertical, density),
213
224
  )
214
225
  clipToPadding = true
215
226
  requestLayout()
@@ -228,7 +239,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
228
239
  this.renderItem = renderItem
229
240
  this.recycleItem = recycleItem
230
241
  this.endReached = endReached
231
- threshold = endReachedThreshold.coerceAtLeast(0.0)
242
+ threshold = endReachedThreshold.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: 0.1
232
243
 
233
244
  val nextColumns = columns.coerceAtLeast(1)
234
245
  val layoutChanged = this.horizontal != horizontal || this.columns != nextColumns
@@ -151,6 +151,9 @@ class CraftNativeMutationDocument {
151
151
 
152
152
  fun node(id: String): JSONObject? = materialize(id, nodes)
153
153
 
154
+ /** The committed parent used to redraw a child that has no native view. */
155
+ fun parentId(id: String): String? = nodes[id]?.parent
156
+
154
157
  private fun createNode(
155
158
  operation: JSONObject,
156
159
  nodes: MutableMap<String, Node>,
@@ -271,7 +274,7 @@ class CraftNativeMutationDocument {
271
274
  val CONTAINER_TYPES = setOf(
272
275
  "View", "SafeAreaView", "ScrollView", "KeyboardAvoidingView", "Modal", "Pressable",
273
276
  "TouchableOpacity", "TouchableHighlight", "ImageBackground", "FlatList", "SectionList",
274
- "Fragment", "Svg", "G",
277
+ "Fragment", "Svg", "G", "Map",
275
278
  )
276
279
  }
277
280