diff --git a/app/src/main/java/dev/castarr/tv/CrashLog.kt b/app/src/main/java/dev/castarr/tv/CrashLog.kt new file mode 100644 index 0000000..d6401f0 --- /dev/null +++ b/app/src/main/java/dev/castarr/tv/CrashLog.kt @@ -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, keep: Int = KEEP): List = + entries.filter { it.isNotBlank() }.takeLast(keep) + + fun entries(context: Context): List = 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)) + } +} diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index b549eb0..d51388b 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -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( diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index fa50db5..30341da 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -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++ + } } } diff --git a/tests/unit/CrashLogTest.kt b/tests/unit/CrashLogTest.kt new file mode 100644 index 0000000..0cc63ff --- /dev/null +++ b/tests/unit/CrashLogTest.kt @@ -0,0 +1,47 @@ +package dev.castarr.tv + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CrashLogTest { + + @Test + fun `an entry names the time, the version and the top of the stack`() { + val error = IllegalStateException("Senderliste leer") + // 26.08.2026, 19:12 (MESZ) + val entry = CrashLog.render(1787764320000L, "0.12.0", error) + val lines = entry.lines() + assertTrue(lines.first().contains("0.12.0")) + assertTrue(lines.first().contains("26.08.2026")) + assertTrue(entry.contains("IllegalStateException")) + assertTrue(entry.contains("Senderliste leer")) + } + + @Test + fun `a long stack is cut down to something readable`() { + val error = RuntimeException("tief") + val entry = CrashLog.render(0L, "0.12.0", error) + // Kopfzeile plus höchstens TRACE_LINES Zeilen Stack. + assertTrue(entry.lines().size <= CrashLog.TRACE_LINES + 1) + } + + @Test + fun `only the newest entries are kept`() { + val entries = (1..15).map { "Absturz $it" } + val kept = CrashLog.trim(entries) + assertEquals(CrashLog.KEEP, kept.size) + assertEquals("Absturz 15", kept.last()) + assertEquals("Absturz 6", kept.first()) + } + + @Test + fun `blank entries are dropped instead of counting`() { + assertEquals(listOf("echt"), CrashLog.trim(listOf("", " ", "echt"))) + } + + @Test + fun `nothing recorded stays nothing`() { + assertEquals(emptyList(), CrashLog.trim(emptyList())) + } +}