Record crashes on the device so "two weeks without one" can be checked
All checks were successful
Build TV app / build (push) Successful in 46s

The milestone asks for two weeks in the living room without a crash. Nobody
reads logcat on a TV, and by the time someone says "it was gone yesterday"
the log has rotated — the criterion had no evidence behind it.

CrashLog keeps the last ten crashes in the app's own files: timestamp,
version, and the top of the stack. Nothing is sent anywhere. The settings
show either "Kein Absturz aufgezeichnet" or the newest one, and clicking
the row clears the record — so the two weeks start when you say they do.

Verified by crashing the app on purpose: the entry appears as
"26.08.2026 18:10 · 0.11.5" with the exception below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 19:50:01 +02:00
parent 660837c116
commit 06b31f0b9d
4 changed files with 132 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
package dev.castarr.tv
import android.content.Context
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Keeps a short record of crashes on the device.
*
* "Two weeks in the living room without a crash" is only a criterion if a
* crash leaves a trace. Nobody reads logcat on a TV, and by the time someone
* mentions "it was gone yesterday" the log has long rotated.
*
* Deliberately local: no reporting service, nothing leaves the device.
*/
object CrashLog {
private const val FILE = "crashes.log"
private const val SEPARATOR = "\n---\n"
/** How many crashes are kept; older ones fall off the front. */
const val KEEP = 10
/** Lines of stack trace per entry — enough to place it, not a dump. */
const val TRACE_LINES = 12
fun install(context: Context, version: String) {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
runCatching { append(context, render(System.currentTimeMillis(), version, error)) }
previous?.uncaughtException(thread, error)
}
}
/** One entry: when, which version, and the top of the stack. */
fun render(at: Long, version: String, error: Throwable): String {
val stamp = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY).format(Date(at))
val trace = error.stackTraceToString()
.lineSequence()
.take(TRACE_LINES)
.joinToString("\n")
return "$stamp · $version\n$trace"
}
/** Newest last, at most [KEEP] entries. */
fun trim(entries: List<String>, keep: Int = KEEP): List<String> =
entries.filter { it.isNotBlank() }.takeLast(keep)
fun entries(context: Context): List<String> = runCatching {
val file = File(context.filesDir, FILE)
if (!file.exists()) return emptyList()
file.readText().split(SEPARATOR).filter { it.isNotBlank() }
}.getOrDefault(emptyList())
/** First line of the newest entry, or null when nothing ever crashed. */
fun lastSummary(context: Context): String? =
entries(context).lastOrNull()?.lineSequence()?.firstOrNull()
fun clear(context: Context) {
runCatching { File(context.filesDir, FILE).delete() }
}
private fun append(context: Context, entry: String) {
val file = File(context.filesDir, FILE)
val kept = trim(entries(context) + entry)
file.writeText(kept.joinToString(SEPARATOR))
}
}

View File

@@ -37,6 +37,8 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before anything else, so a crash during start-up is recorded too.
CrashLog.install(applicationContext, BuildConfig.VERSION_NAME)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
val auth = DeviceAuth(this)
state = AppState(

View File

@@ -259,6 +259,19 @@ fun SettingsScreen(state: AppState) {
"Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen",
) { state.screen = AppState.Screen.ADVANCED }
// "Zwei Wochen ohne Absturz" ist nur nachprüfbar, wenn ein
// Absturz überhaupt eine Spur hinterlässt.
val lastCrash = remember(pairingEpoch) {
dev.castarr.tv.CrashLog.lastSummary(context)
}
SettingRow(
"Stabilität",
subtitle = lastCrash?.let { "Letzter Absturz: $it · Klicken löscht" }
?: "Kein Absturz aufgezeichnet",
) {
dev.castarr.tv.CrashLog.clear(context)
pairingEpoch++
}
}
}