craft-native 0.0.174 → 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.
- package/dist/android/src/index.d.ts +19 -0
- package/dist/android/src/index.js +19 -2
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftBridge.kt.template +51 -0
- package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
- package/dist/android/templates/CraftNative.kt.template +17 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
- package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
- package/dist/android/templates/CraftNativeServices.kt.template +31 -1
- package/dist/android/templates/MainActivityNative.kt.template +998 -303
- package/dist/android/templates/build.gradle.kts.app.template +4 -2
- package/dist/api/mobile.d.ts +31 -0
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +203 -2
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +19 -0
- package/dist/ios/src/index.js +7 -1
- package/dist/ios/templates/CraftApp.swift +51 -1
- package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
- package/dist/ios/templates/CraftHybrid.swift +10 -0
- package/dist/ios/templates/CraftNativeActions.swift +49 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +13 -21
- package/dist/ios/templates/CraftNativeMutation.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +410 -55
- package/dist/ios/templates/project.yml.template +1 -0
- package/dist/macos/templates/CraftNativeMutation.swift +1 -1
- package/dist/macos/templates/native_layout.zig +389 -17
- package/dist/mobile.js +66 -0
- 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
|
-
|
|
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
|
|
206
|
-
val horizontal = (style
|
|
207
|
-
val vertical = (style
|
|
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
|
-
(
|
|
210
|
-
(
|
|
211
|
-
(
|
|
212
|
-
(
|
|
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
|
|