diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt index c0df6f5..dc426a9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt @@ -52,6 +52,46 @@ object GearslipLog { file = File(dir, "gearslip-$name.log") i("log file: ${file?.absolutePath}") SessionReport.init(context) + // One file per launch adds up; the newest are the ones anyone asks for. + io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } } + } + + private const val KEEP_FILES = 30 + + private fun logFiles(dir: File): List = + dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") } + ?.sortedByDescending { it.name }.orEmpty() + + /** A log from an earlier launch: when it started, and what a glance at it tells. */ + class PastLog(val file: File, val started: Date?, val crashed: Boolean, val car: String?) + + /** + * Earlier launches' logs, newest first. Each launch starts a fresh file, so after a crash the + * one that saw it is here rather than on the live screen. + */ + fun pastLogs(context: Context): List { + val dir = context.getExternalFilesDir(null) ?: return emptyList() + val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US) + return logFiles(dir).filter { it != file }.map { f -> + val text = runCatching { f.readText() }.getOrDefault("") + PastLog( + file = f, + started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(), + crashed = "FATAL uncaught" in text, + car = Regex("""car="([^"]+)"""").findAll(text).lastOrNull()?.groupValues?.get(1), + ) + } + } + + /** An earlier launch's log, copied to the cache dir for sharing like [shareableFile]. */ + fun shareableFile(context: Context, past: File): android.net.Uri { + val dir = File(context.cacheDir, "shared").apply { mkdirs() } + dir.listFiles()?.forEach { it.delete() } + val out = File(dir, "gearslip-log-${past.name.removePrefix("gearslip-").removeSuffix(".log")}.txt") + past.copyTo(out, overwrite = true) + return androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.logshare", out, + ) } fun snapshot(): List = synchronized(live) { live.toList() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/LogsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/LogsScreen.kt index 07d1ee6..7ed6012 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/LogsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/LogsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -55,6 +56,9 @@ fun LogsScreen(onBack: () -> Unit) { val version by GearslipLog.version.collectAsStateWithLifecycle() val lines = remember(version) { GearslipLog.snapshot() } + var pastOpen by remember { mutableStateOf(false) } + if (pastOpen) PastLogsDialog(onDismiss = { pastOpen = false }) + val listState = rememberLazyListState() val atEnd by remember { derivedStateOf { @@ -96,17 +100,16 @@ fun LogsScreen(onBack: () -> Unit) { Button( onClick = { scope.launch { - val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) } - val send = Intent(Intent.ACTION_SEND) - .setType("text/plain") - .putExtra(Intent.EXTRA_SUBJECT, "Gearslip log") - .putExtra(Intent.EXTRA_STREAM, uri) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - context.startActivity(Intent.createChooser(send, "Share log")) - } + val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) } + shareLog(context, uri) + } }, modifier = Modifier.weight(1f).height(56.dp), ) { Text("Share") } + OutlinedButton( + onClick = { pastOpen = true }, + modifier = Modifier.weight(1f).height(56.dp), + ) { Text("Earlier") } OutlinedButton( onClick = { GearslipLog.clearLive() }, modifier = Modifier.weight(1f).height(56.dp), @@ -153,6 +156,68 @@ fun LogsScreen(onBack: () -> Unit) { } } +private fun shareLog(context: android.content.Context, uri: android.net.Uri) { + val send = Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_SUBJECT, "Gearslip log") + .putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity(Intent.createChooser(send, "Share log")) +} + +/** + * Logs from earlier launches. Opening the app after a crash starts a new log, so the one that + * saw the crash is only reachable here. + */ +@Composable +private fun PastLogsDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val logs by androidx.compose.runtime.produceState?>(null) { + value = withContext(Dispatchers.IO) { GearslipLog.pastLogs(context) } + } + val format = remember { java.text.SimpleDateFormat("EEE d MMM, h:mm a", java.util.Locale.getDefault()) } + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Earlier logs") }, + text = { + val list = logs + when { + list == null -> Text("Loading…") + list.isEmpty() -> Text("No earlier logs yet. Each time Gearslip starts, it begins a new one.") + else -> LazyColumn { + itemsIndexed(list) { _, log -> + androidx.compose.foundation.layout.Column( + Modifier + .fillMaxWidth() + .clickable { + scope.launch { + val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context, log.file) } + shareLog(context, uri) + } + } + .padding(vertical = 12.dp), + ) { + Text(log.started?.let(format::format) ?: log.file.name, style = MaterialTheme.typography.titleMedium) + val detail = listOfNotNull( + "Crashed".takeIf { log.crashed }, + log.car, + "${(log.file.length() + 1023) / 1024} KB", + ).joinToString(" · ") + Text( + detail, + style = MaterialTheme.typography.bodyMedium, + color = if (log.crashed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + }, + confirmButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} + /** Lines look like "HH:mm:ss.SSS L message"; the level letter sits at index 13. */ @Composable private fun colorFor(line: String): Color = when (line.getOrNull(13)) {