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
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:
70
app/src/main/java/dev/castarr/tv/CrashLog.kt
Normal file
70
app/src/main/java/dev/castarr/tv/CrashLog.kt
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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
|
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||||
val auth = DeviceAuth(this)
|
val auth = DeviceAuth(this)
|
||||||
state = AppState(
|
state = AppState(
|
||||||
|
|||||||
@@ -259,6 +259,19 @@ fun SettingsScreen(state: AppState) {
|
|||||||
"Erweitert",
|
"Erweitert",
|
||||||
subtitle = "M3U/EPG-Adressen von Hand eintragen",
|
subtitle = "M3U/EPG-Adressen von Hand eintragen",
|
||||||
) { state.screen = AppState.Screen.ADVANCED }
|
) { 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++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
tests/unit/CrashLogTest.kt
Normal file
47
tests/unit/CrashLogTest.kt
Normal file
@@ -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<String>(), CrashLog.trim(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user