@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.
- package/README.md +12 -2
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -3
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/templates/AndroidManifest.xml.template +1 -0
- package/templates/CraftBridge.kt.template +51 -0
- package/templates/CraftCrashReporter.kt.template +544 -0
- package/templates/CraftNative.kt.template +17 -0
- package/templates/CraftNativeFlatList.kt.template +20 -9
- package/templates/CraftNativeMutation.kt.template +4 -1
- package/templates/CraftNativeServices.kt.template +31 -1
- package/templates/MainActivityNative.kt.template +986 -304
- package/templates/build.gradle.kts.app.template +4 -2
|
@@ -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
|
*
|