log: HttpSink + native crash capture (iOS/Android)
Three new pieces, all composable through the existing Log API
(`Log.configure(sink: ConsoleSink() + HttpSink(...))`) — no new
facade, no install side-effects.
HttpSink (lib/src/log_http.dart)
- Extends LogSink. Batches records and POSTs them as a JSON array
to a configurable endpoint with bearer auth.
- Defaults: batchSize=25, flushInterval=2s, queueCapacity=2000,
initialBackoff=1s capped at maxBackoff=30s.
- Drops oldest on queue overflow (single console warning).
- Retries 5xx and network errors with exponential backoff; drops
on 4xx with a single console warning.
- Pluggable `HttpSender` typedef for tests; default uses
dart:io.HttpClient.
CrashPlugin (ios/Classes/CrashPlugin.swift,
android/src/main/kotlin/.../CrashPlugin.kt)
- Installs uncaught-exception handlers
(NSSetUncaughtExceptionHandler / Thread.UncaughtExceptionHandler),
chains to the prior handler so the platform's default kill path
still runs.
- Writes one JSON file per crash to <cacheDir>/ux_crashes/<uuid>.json.
iOS captures NSException.name/reason/userInfo + call-stack symbols
and return addresses. Android captures thread name, exception
class, message, full stack (including cause chain).
- Caps the directory at 50 files; drops oldest by mtime on overflow.
- Exposes method channel `ux/crash` with drainPending / ackCrash /
triggerTestCrash. Registered in UxPlugin on both platforms.
UxCrash.drainAndReport (lib/src/crash.dart)
- Pulls persisted crash records on boot, re-emits each via Log.f
(tag `ux.crash`) so they flow out through whatever sink chain
the app installed, then acks each id.
- Tolerates MissingPluginException silently; PlatformException is
logged as a single warn without throwing.
Tests:
- log_http_test.dart: payload shape, batching, retry doubling on 5xx,
drop on 4xx, queue overflow ordering, non-encodable field
stringification, real loopback HTTP round-trip with the default
sender.
- log_http_e2e_test.dart: opt-in real-server round-trip gated by
--dart-define=E2E_LOG_ENDPOINT/E2E_LOG_TOKEN.
- crash_test.dart: drain + re-emit + ack across iOS and Android
shapes, MissingPluginException tolerance, PlatformException
warn-not-throw.
This commit is contained in:
149
android/src/main/kotlin/io/swipelab/ux/CrashPlugin.kt
Normal file
149
android/src/main/kotlin/io/swipelab/ux/CrashPlugin.kt
Normal file
@@ -0,0 +1,149 @@
|
||||
package io.swipelab.ux
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
class CrashPlugin : NativePlugin, MethodChannel.MethodCallHandler {
|
||||
private var methodChannel: MethodChannel? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
val ctx = binding.applicationContext
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, "ux/crash").also {
|
||||
it.setMethodCallHandler(this)
|
||||
}
|
||||
installHandlerOnce(ctx)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel?.setMethodCallHandler(null)
|
||||
methodChannel = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"drainPending" -> handleDrain(result)
|
||||
"ackCrash" -> handleAck(call, result)
|
||||
"triggerTestCrash" -> handleTriggerTestCrash(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTriggerTestCrash(result: MethodChannel.Result) {
|
||||
result.success(null)
|
||||
Thread({ throw RuntimeException("ux/crash triggerTestCrash") }, "ux-crash-test").start()
|
||||
}
|
||||
|
||||
private fun handleDrain(result: MethodChannel.Result) {
|
||||
val dir = crashDir
|
||||
if (dir == null || !dir.isDirectory) return result.success(emptyList<Map<String, Any?>>())
|
||||
val files = dir.listFiles { _, name -> name.endsWith(".json") }?.toList().orEmpty()
|
||||
.sortedBy { it.lastModified() }
|
||||
val out = ArrayList<Map<String, Any?>>(files.size)
|
||||
for (f in files) {
|
||||
val entry = try {
|
||||
val json = JSONObject(f.readText(Charsets.UTF_8))
|
||||
jsonToMap(json).toMutableMap().apply { put("id", f.nameWithoutExtension) }
|
||||
} catch (_: Exception) {
|
||||
f.delete()
|
||||
continue
|
||||
}
|
||||
out.add(entry)
|
||||
}
|
||||
result.success(out)
|
||||
}
|
||||
|
||||
private fun handleAck(call: MethodCall, result: MethodChannel.Result) {
|
||||
val id = call.arguments as? String
|
||||
?: return result.error("bad_args", "expected String id", null)
|
||||
crashDir?.let { File(it, "$id.json").delete() }
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var installed = false
|
||||
private var priorHandler: Thread.UncaughtExceptionHandler? = null
|
||||
private var crashDir: File? = null
|
||||
|
||||
private fun installHandlerOnce(ctx: Context) {
|
||||
if (installed) return
|
||||
installed = true
|
||||
crashDir = File(ctx.cacheDir, "ux_crashes").apply { mkdirs() }
|
||||
priorHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler(::uxCrashHandler)
|
||||
}
|
||||
|
||||
private fun uxCrashHandler(thread: Thread, throwable: Throwable) {
|
||||
val dir = crashDir
|
||||
if (dir != null) {
|
||||
runCatching {
|
||||
val jsons = dir.listFiles { _, name -> name.endsWith(".json") }?.toList().orEmpty()
|
||||
if (jsons.size >= 50) {
|
||||
jsons.sortedBy { it.lastModified() }
|
||||
.take(jsons.size - 49)
|
||||
.forEach { it.delete() }
|
||||
}
|
||||
|
||||
val sw = StringWriter()
|
||||
throwable.printStackTrace(PrintWriter(sw))
|
||||
val payload = JSONObject().apply {
|
||||
put("platform", "android")
|
||||
put("time", iso8601(Date()))
|
||||
put("thread", thread.name)
|
||||
put("type", throwable.javaClass.name)
|
||||
put("message", throwable.message ?: "")
|
||||
put("stack", sw.toString())
|
||||
put("cause", throwable.cause?.toString() ?: "")
|
||||
put("sdkInt", Build.VERSION.SDK_INT)
|
||||
put("packageName", dir.parentFile?.parentFile?.name ?: "")
|
||||
}
|
||||
val tmp = File(dir, "${UUID.randomUUID()}.json.tmp")
|
||||
tmp.writeText(payload.toString(), Charsets.UTF_8)
|
||||
tmp.renameTo(File(dir, tmp.name.removeSuffix(".tmp")))
|
||||
}
|
||||
}
|
||||
// Hand off to the prior handler so the platform's default crash
|
||||
// behavior (process kill, ANR dialog, etc.) still runs.
|
||||
priorHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
|
||||
private fun iso8601(d: Date): String {
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
fmt.timeZone = TimeZone.getTimeZone("UTC")
|
||||
return fmt.format(d)
|
||||
}
|
||||
|
||||
private fun jsonToMap(o: JSONObject): Map<String, Any?> {
|
||||
val out = LinkedHashMap<String, Any?>(o.length())
|
||||
val it = o.keys()
|
||||
while (it.hasNext()) {
|
||||
val k = it.next()
|
||||
out[k] = jsonValue(o.opt(k))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun jsonValue(v: Any?): Any? = when (v) {
|
||||
null, JSONObject.NULL -> null
|
||||
is JSONObject -> jsonToMap(v)
|
||||
is JSONArray -> {
|
||||
val list = ArrayList<Any?>(v.length())
|
||||
for (i in 0 until v.length()) list.add(jsonValue(v.opt(i)))
|
||||
list
|
||||
}
|
||||
else -> v
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
|
||||
ScannerPlugin(),
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
CrashPlugin(),
|
||||
)
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
||||
|
||||
Reference in New Issue
Block a user