@@ -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+}