1package soy.iko.opencode.data.repo
3import android.content.Context
4import android.content.pm.PackageManager
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
19import java.io.PrintWriter
20import java.io.StringWriter
21import java.text.SimpleDateFormat
23import java.util.Locale
24import soy.iko.opencode.util.runCatchingCancellable
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.
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.
36class CrashLogger private constructor(private val appContext: Context) {
38 data class CrashReport(
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()
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)
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() } }
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)
69 val generation = refreshGeneration.incrementAndGet()
70 val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
71 ?.sortedByDescending { it.lastModified() }
75 timestamp = f.lastModified(),
76 preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
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
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()
91 fun deleteReport(fileName: String) {
93 val file = File(crashDir, fileName).canonicalFile
94 if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
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>()
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.
111 fun scheduleDelete(fileName: String, delayMs: Long) {
112 val job = scope.launch {
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)) {
129 pendingDeletes.put(fileName, job)?.cancel()
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
142 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
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. */
156 private fun writeReport(thread: Thread, throwable: Throwable) {
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()}")
173 pw.println(throwable.javaClass.name + ": " + throwable.message)
174 throwable.printStackTrace(pw)
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()))
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),
191 @Suppress("DEPRECATION")
192 appContext.packageManager.getPackageInfo(appContext.packageName, 0)
194 "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
195 }.getOrDefault("unknown")
198 /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
199 internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
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.
206 internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
209 private var instance: CrashLogger? = null
211 fun get(context: Context): CrashLogger =
212 instance ?: synchronized(this) {
213 instance ?: CrashLogger(context.applicationContext).also { instance = it }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list