git.iko.soy/opencode-android
on c0621ff help sign in
216 lines 9.9 kB history raw
1package soy.iko.opencode.data.repo
3import android.content.Context
4import android.content.pm.PackageManager
5import android.os.Build
6import android.util.Log
7import androidx.core.content.pm.PackageInfoCompat
8import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.Dispatchers
10import kotlinx.coroutines.Job
11import kotlinx.coroutines.SupervisorJob
12import kotlinx.coroutines.cancel
13import kotlinx.coroutines.delay
14import kotlinx.coroutines.flow.MutableStateFlow
15import kotlinx.coroutines.flow.StateFlow
16import kotlinx.coroutines.flow.asStateFlow
17import kotlinx.coroutines.launch
18import java.io.File
19import java.io.PrintWriter
20import java.io.StringWriter
21import java.text.SimpleDateFormat
22import java.util.Date
23import java.util.Locale
24import soy.iko.opencode.util.runCatchingCancellable
26/**
27 * A self-contained crash reporter with no network or third-party backend. An uncaught
28 * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
29 * text report (timestamp, device/app metadata, and the full stack trace), then the
30 * previous handler is re-invoked so the process still dies normally.
31 *
32 * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
33 * shared, or deleted. This closes the "no idea what's crashing in the field" gap
34 * without pulling in a hosted analytics SDK.
35 */
36class CrashLogger private constructor(private val appContext: Context) {
38 data class CrashReport(
39 val fileName: String,
40 val timestamp: Long,
41 val preview: String,
42 )
44 private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
47 private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
48 val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
50 fun install() {
51 val previous = Thread.getDefaultUncaughtExceptionHandler()
52 Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
53 runCatching { writeReport(thread, throwable) }
54 .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
55 previous?.uncaughtException(thread, throwable)
56 }
57 // Load crash reports off the main thread so startup isn't blocked by file I/O
58 // (listFiles + reading the first line of each report). The StateFlow updates
59 // whenever the scan completes, so the Diagnostics screen reflects the result.
60 scope.launch { runCatchingCancellable { refresh() } }
61 }
63 /** Monotonic guard so a slower earlier scan can't overwrite a newer one's result.
64 * refresh() runs on the multi-threaded IO dispatcher from several call sites
65 * (install/deleteReport/scheduleDelete/clearAll), so two scans can overlap. */
66 private val refreshGeneration = java.util.concurrent.atomic.AtomicLong(0)
68 fun refresh() {
69 val generation = refreshGeneration.incrementAndGet()
70 val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
71 ?.sortedByDescending { it.lastModified() }
72 ?.map { f ->
73 CrashReport(
74 fileName = f.name,
75 timestamp = f.lastModified(),
76 preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
77 )
78 }
79 .orEmpty()
80 // Publish only if no newer refresh has started meanwhile, so a stale scan (e.g. one
81 // that started before a delete) can't momentarily re-show a just-removed report.
82 if (generation == refreshGeneration.get()) _reports.value = scanned
83 }
85 fun readReport(fileName: String): String? {
86 val file = File(crashDir, fileName).canonicalFile
87 if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
88 return runCatching { file.readText() }.getOrNull()
89 }
91 fun deleteReport(fileName: String) {
92 scope.launch {
93 val file = File(crashDir, fileName).canonicalFile
94 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
95 file.delete()
96 }
97 refresh()
98 }
99 }
101 /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
102 private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
104 /**
105 * Schedule [fileName] for deletion after [delayMs], cancellable via
106 * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
107 * Diagnostics screen's composition scope — so navigating away during the undo window
108 * still commits the delete instead of silently dropping it. A repeated schedule for
109 * the same name replaces the prior timer.
110 */
111 fun scheduleDelete(fileName: String, delayMs: Long) {
112 val job = scope.launch {
113 delay(delayMs)
114 // Atomically claim the delete by removing our own map entry BEFORE deleting.
115 // The remove is the synchronization point with cancelScheduledDelete: whichever
116 // removes the entry first wins. If an undo raced the timer and removed it first,
117 // this returns false and we skip the delete — so the file survives, matching the
118 // `true` that cancel returned to the UI. Deleting first (then removing) would let
119 // a cancel land in the gap and falsely report "undo succeeded" for a gone file.
120 // remove(name, thisJob) also no-ops for a stale job after a reschedule.
121 val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
122 if (!claimed) return@launch
123 val file = File(crashDir, fileName).canonicalFile
124 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
125 file.delete()
126 }
127 refresh()
128 }
129 pendingDeletes.put(fileName, job)?.cancel()
130 }
132 /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
133 * pending (undo succeeded), false if the delete had already fired. */
134 fun cancelScheduledDelete(fileName: String): Boolean {
135 val job = pendingDeletes.remove(fileName) ?: return false
136 job.cancel()
137 return true
138 }
140 fun clearAll() {
141 scope.launch {
142 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
143 refresh()
144 }
145 }
147 fun reportCount(): Int = _reports.value.size
149 /** Cancel the internal coroutine scope so background work doesn't outlive the
150 * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
151 * intentionally left installed — a crash during shutdown still needs to be logged. */
152 fun shutdown() {
153 scope.cancel()
154 }
156 private fun writeReport(thread: Thread, throwable: Throwable) {
157 val now = Date()
158 val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
159 // Append a short random suffix so two crashes in the same millisecond don't
160 // overwrite each other.
161 val suffix = java.lang.Long.toHexString(System.nanoTime())
162 val file = File(crashDir, "crash-$stamp-$suffix.txt")
163 val sw = StringWriter()
164 PrintWriter(sw).use { pw ->
165 pw.println("opencode-android crash report")
166 pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
167 pw.println("Thread: ${thread.name}")
168 pw.println("App version: ${appVersion()}")
169 pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
170 pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
171 pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
172 pw.println()
173 pw.println(throwable.javaClass.name + ": " + throwable.message)
174 throwable.printStackTrace(pw)
175 }
176 // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
177 // message (and every "Caused by:" message) verbatim, so scrubbing only the
178 // top-level message above is futile — Ktor/OkHttp embed the full request URL
179 // in those messages, which may contain auth or internal paths. Scrubbing the
180 // assembled text guarantees no URL survives anywhere in the stored report.
181 file.writeText(scrubUrls(sw.toString()))
182 }
184 private fun appVersion(): String = runCatching {
185 val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
186 appContext.packageManager.getPackageInfo(
187 appContext.packageName,
188 PackageManager.PackageInfoFlags.of(0),
189 )
190 } else {
191 @Suppress("DEPRECATION")
192 appContext.packageManager.getPackageInfo(appContext.packageName, 0)
193 }
194 "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
195 }.getOrDefault("unknown")
197 companion object {
198 /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
199 internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
201 /**
202 * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
203 * redaction (used on the whole crash report, including exception messages and
204 * stack traces produced by printStackTrace) can be unit-tested in isolation.
205 */
206 internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
208 @Volatile
209 private var instance: CrashLogger? = null
211 fun get(context: Context): CrashLogger =
212 instance ?: synchronized(this) {
213 instance ?: CrashLogger(context.applicationContext).also { instance = it }
214 }
215 }