git.iko.soy/opencode-android
on master help sign in
Add crash reporting with local persistence and global uncaught-exception handler
52cef25iko
app/src/main/AndroidManifest.xml+1
@@ -6,6 +6,7 @@
6 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7
7
8 <application
8 <application
9+ android:name=".OpencodeApp"
9 android:allowBackup="true"
10 android:allowBackup="true"
10 android:label="@string/app_name"
11 android:label="@string/app_name"
11 android:supportsRtl="true"
12 android:supportsRtl="true"
app/src/main/java/soy/iko/opencode/CrashLogger.ktnew file+74
@@ -0,0 +1,74 @@
1+package soy.iko.opencode
2+
3+import android.content.Context
4+import android.util.Log
5+import java.io.File
6+import java.io.PrintWriter
7+import java.io.StringWriter
8+import java.text.SimpleDateFormat
9+import java.util.Date
10+import java.util.Locale
11+
12+/**
13+ * Lightweight crash reporter that installs as the global uncaught-exception
14+ * handler. Crashes are logged to logcat and persisted to internal storage so
15+ * they survive process death and can be inspected or exported later.
16+ */
17+object CrashLogger {
18+
19+ private const val TAG = "opencode-crash"
20+ private const val CRASH_DIR = "crashes"
21+ private const val MAX_REPORTS = 10
22+
23+ private lateinit var appContext: Context
24+ private var previousHandler: Thread.UncaughtExceptionHandler? = null
25+
26+ fun init(context: Context) {
27+ appContext = context.applicationContext
28+ previousHandler = Thread.getDefaultUncaughtExceptionHandler()
29+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
30+ report(throwable, thread.name)
31+ previousHandler?.uncaughtException(thread, throwable)
32+ }
33+ }
34+
35+ fun report(throwable: Throwable, source: String = Thread.currentThread().name) {
36+ val stackTrace = stackTraceString(throwable)
37+ Log.e(TAG, "Uncaught exception in [$source]\n$stackTrace", throwable)
38+ persistReport(source, stackTrace)
39+ }
40+
41+ fun getReports(): List<File> =
42+ crashDir().listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
43+
44+ fun clearReports() {
45+ crashDir().listFiles()?.forEach { it.delete() }
46+ }
47+
48+ private fun persistReport(source: String, stackTrace: String) {
49+ try {
50+ val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
51+ val file = File(crashDir(), "crash_${timestamp}.txt")
52+ file.writeText("Time: $timestamp\nThread: $source\n\n$stackTrace")
53+ pruneOldReports()
54+ } catch (e: Exception) {
55+ Log.e(TAG, "Failed to persist crash report", e)
56+ }
57+ }
58+
59+ private fun pruneOldReports() {
60+ val reports = getReports()
61+ if (reports.size > MAX_REPORTS) {
62+ reports.drop(MAX_REPORTS).forEach { it.delete() }
63+ }
64+ }
65+
66+ private fun crashDir(): File =
67+ File(appContext.filesDir, CRASH_DIR).apply { mkdirs() }
68+
69+ private fun stackTraceString(throwable: Throwable): String {
70+ val sw = StringWriter()
71+ throwable.printStackTrace(PrintWriter(sw))
72+ return sw.toString()
73+ }
74+}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+10
@@ -0,0 +1,10 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+
5+class OpencodeApp : Application() {
6+ override fun onCreate() {
7+ super.onCreate()
8+ CrashLogger.init(this)
9+ }
10+}
app/src/main/java/soy/iko/opencode/core/Core.kt+2
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
5import kotlinx.coroutines.flow.StateFlow
5import kotlinx.coroutines.flow.StateFlow
6import kotlinx.coroutines.flow.asStateFlow
6import kotlinx.coroutines.flow.asStateFlow
7import kotlinx.coroutines.launch
7import kotlinx.coroutines.launch
8+import soy.iko.opencode.CrashLogger
8
9
9/**
10/**
10 * The bridge between the Crux core (Rust or pure-Kotlin fallback) and the
11 * The bridge between the Crux core (Rust or pure-Kotlin fallback) and the
@@ -76,6 +77,7 @@ class Core : androidx.lifecycle.ViewModel() {
76 return try {
77 return try {
77 ViewModel.bincodeDeserialize(ffi.view())
78 ViewModel.bincodeDeserialize(ffi.view())
78 } catch (e: Throwable) {
79 } catch (e: Throwable) {
80+ CrashLogger.report(e, "Core.getInitialView")
79 ViewModel(
81 ViewModel(
80 screen = Screen.CONNECT,
82 screen = Screen.CONNECT,
81 serverUrl = "http://localhost:4096",
83 serverUrl = "http://localhost:4096",
app/src/main/java/soy/iko/opencode/core/SseClient.kt+3−2
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
14import kotlinx.coroutines.flow.SharedFlow
14import kotlinx.coroutines.flow.SharedFlow
15import kotlinx.coroutines.flow.asSharedFlow
15import kotlinx.coroutines.flow.asSharedFlow
16import kotlinx.coroutines.launch
16import kotlinx.coroutines.launch
17+import soy.iko.opencode.CrashLogger
17
18
18/**
19/**
19 * Subscribes to the opencode server's SSE event stream.
20 * Subscribes to the opencode server's SSE event stream.
@@ -53,8 +54,8 @@ class SseClient {
53 }
54 }
54 }
55 }
55 }
56 }
56− } catch (_: Throwable) {
57+ } catch (e: Throwable) {
57− // Connection lost — the Core will retry on next event cycle
58+ CrashLogger.report(e, "SseClient")
58 }
59 }
59 }
60 }
60 }
61 }