logs: share a log from an earlier launch

Opening Gearslip after a crash starts a new log, so the one that saw the
crash could only be pulled with a computer. Logs now has an Earlier button
listing past launches, newest first, marked when they crashed; tapping one
shares it. The newest 30 are kept.
This commit is contained in:
Seb3thehacker committed 2026-10-19 22:15:21 -07:00
1 parent 325e115752
commit 86a1f968ec
2 files changed
+113 -8

No files matched your search

@@ -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<File> =
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<PastLog> {
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<String> = synchronized(live) { live.toList() }
@@ -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<List<GearslipLog.PastLog>?>(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)) {