Merge pull request #122 from Droid-Deck/feat/readable-session-logs

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-01 07:48:03 -04:00
commit 445bbcdcbe
10 files changed
+189 -23

No files matched your search

@@ -475,6 +475,16 @@ class MainActivity : ComponentActivity() {
}
}, "share-logs").start()
},
onClearLogs = {
Thread({
val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0)
ui.post {
android.widget.Toast.makeText(this,
if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.",
android.widget.Toast.LENGTH_SHORT).show()
}
}, "clear-logs").start()
},
onOffline = {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
@@ -624,6 +634,7 @@ class MainActivity : ComponentActivity() {
if (!SessionState.running) Thread({
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
SessionArtifacts.prune(this)
}, "finish-abandoned").start()
}
@@ -227,7 +227,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
SessionEvents.begin(this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
// A Flatpak app or AppImage is named by its arguments, which the name lookup reads here.
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
}
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
@@ -586,7 +589,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun shareCurrentSessionLogs() {
val folder = SessionPaths.current()
// Once a session has ended its folder has been handed on (SessionPaths.take), and the
// ended screen's Share logs is for that one.
val folder = SessionPaths.current() ?: SessionPaths.lastEnded()
if (folder == null || !folder.isDirectory) {
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
return
@@ -5,6 +5,7 @@ import android.os.Environment
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
@@ -16,13 +17,15 @@ object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
* says also goes to Download/DroidDeck/tools/<logName>.log beside the session logs, so a problem can
* be handed over like a session's.
*/
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
onLine: (String) -> Unit): Int {
val log = logName?.let {
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
try {
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
} catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
@@ -66,7 +66,7 @@ object SessionArtifacts {
fun scrubOlder(context: Context) {
val current = SessionPaths.current()
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
f.isDirectory && f.name.startsWith("session-") && f != current && !File(f, SCRUBBED_MARKER).exists()
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
} ?: return
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
@@ -124,8 +124,8 @@ object SessionArtifacts {
fun finishAbandoned(context: Context) {
val parent = LinuxRuntime.debugLogDir()
val abandoned = parent.listFiles { f ->
f.isDirectory && f.name.startsWith("session-") && !File(f, COMPLETE_MARKER).exists()
}?.sortedBy { it.name } ?: return
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
}?.sortedWith(SessionPaths.chronological) ?: return
if (abandoned.isEmpty()) return
val current = SessionPaths.current()
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
@@ -157,5 +157,37 @@ object SessionArtifacts {
}
}
/**
* Keeps the newest [SessionPaths.KEEP_SESSIONS] session folders and deletes the rest: a few
* days of testing left hundreds, and the one a report needed was lost among them. Only folders
* that are finished; the session in progress is never touched. Runs at app start after
* [finishAbandoned], on its worker thread.
*/
@Synchronized
fun prune(context: Context) {
val current = SessionPaths.current()
val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() }
val old = finished.dropLast(SessionPaths.KEEP_SESSIONS)
old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}")
}
/**
* Every session folder but the one in progress, and the one-off command logs (tools/, or loose
* beside the folders from before it): the Setup page's Clear logs. Returns how many session
* folders went.
*/
@Synchronized
fun clearAll(context: Context): Int {
val current = SessionPaths.current()
val gone = SessionPaths.sessionFolders(context).filter { it != current }
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
// Before tools/, those logs sat loose beside the session folders.
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
Log.i(TAG, "cleared ${gone.size} session folder(s)")
return gone.size
}
private fun now(): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
}
@@ -11,11 +11,12 @@ object SessionEvents {
private const val TAG = "SessionEvents"
private val lock = Any()
fun begin(context: Context, mode: String) {
/** [label] names the session's folder for what is being run (SessionPaths.label). */
fun begin(context: Context, mode: String, label: String? = null) {
// A runtime-install failure can leave the folder claimed before the service ever starts.
// Keep that folder, but give the next attempt its own session directory.
SessionPaths.take()
val dir = SessionPaths.beginOrCurrent(context)
val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null))
synchronized(SessionState) {
SessionState.phase = SessionPhase.PREPARING
SessionState.mode = mode
@@ -17,10 +17,7 @@ import java.util.zip.ZipOutputStream
*/
object SessionLogShare {
/** The newest session folder in either place logs are written, or null if there is none. */
fun latest(context: Context): File? =
listOf(LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
.flatMap { dir -> dir.listFiles { f -> f.isDirectory && f.name.startsWith("session-") }?.toList() ?: emptyList() }
.maxByOrNull { it.name }
fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull()
/** Builds the zip (blocking). Returns null when there is no session to share. */
fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) }
@@ -8,18 +8,24 @@ import java.util.Date
import java.util.Locale
/**
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded:
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
* named for the day, that day's session number and what was run, so the one a report is about can
* be picked out without opening any:
*
* ```
* Download/DroidDeck/session-20260921-161256/
* Download/DroidDeck/2026-09-30-03-steam/
* device.txt what this device is, and every setting the session ran with
* session.log the guest session: proot, gamescope, the client's stdout
* wayland.log the app's compositor
* steam.log the Steam client's own log, scrubbed (Steam mode)
* steam/ the rest of the client's logs, scrubbed (Steam mode)
* desktop.log labwc, the panel and the programs on it (desktop mode)
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
* ```
*
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
* pruned with the rest.
*
* The folder is claimed by whoever starts first - the activity starts the compositor before the
* service starts the session - so both write into the same one, and a recreated activity (a
* foldable opening mid-session) joins the folder in progress instead of opening another.
@@ -27,24 +33,44 @@ import java.util.Locale
object SessionPaths {
private const val TAG = "SessionPaths"
/** How many session folders are kept; older ones go at the next app start. */
const val KEEP_SESSIONS = 30
/** Beside the session folders: the logs of one-off commands (GuestCommand's logName). */
const val TOOLS_DIR = "tools"
/** `2026-09-30-03-steam`: day, that day's session number, what was run. */
private val NAMED = Regex("""^(\d{4})-(\d{2})-(\d{2})-(\d{2,})-.+$""")
/** `session-20260930-180642`, from before. */
private val LEGACY = Regex("""^session-(\d{8})-(\d{6})(-\d+)?$""")
@Volatile
private var dir: File? = null
@Volatile
private var ended: File? = null
/**
* The folder for the session now starting, or the one already in progress. With logs turned
* off on the main screen the folder lives in the app's cache instead of Downloads - the
* scripts and the compositor still need somewhere to write - and [release] deletes it.
* [label] says what is being run (see [label]); without one it comes from the session's state.
*/
@Synchronized
fun beginOrCurrent(context: Context): File {
fun beginOrCurrent(context: Context, label: String? = null): File {
dir?.let { if (it.isDirectory) return it }
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context)
else File(context.cacheDir, "session-logs")
val baseName = "session-$stamp"
var made = File(parent, baseName)
var suffix = 2
while (made.exists()) made = File(parent, "$baseName-${suffix++}")
val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
// The day's highest number plus one, not a count: pruning removes the oldest folders, and
// a count would hand out a number already in use.
val last = parent.listFiles()?.mapNotNull { f ->
NAMED.matchEntire(f.name)?.takeIf { f.name.startsWith("$day-") }?.groupValues?.get(4)?.toIntOrNull()
}?.maxOrNull() ?: 0
val what = slug(label ?: label(SessionState.mode, SessionState.program))
var number = last + 1
var made = File(parent, "$day-${"%02d".format(Locale.US, number)}-$what")
while (made.exists()) made = File(parent, "$day-${"%02d".format(Locale.US, ++number)}-$what")
if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made")
dir = made
Log.i(TAG, "session logs: $made" + if (SessionPrefs.logsEnabled(context)) "" else " (logs off: discarded at the end)")
@@ -53,6 +79,9 @@ object SessionPaths {
fun current(): File? = dir
/** The folder of the session that ended last, while it is still there: what its Share logs sends. */
fun lastEnded(): File? = ended?.takeIf { it.isDirectory }
/**
* The ending session takes its folder with it: the next session (one that replaces this one
* in place) then claims a folder of its own instead of sharing, and this one's collector
@@ -60,9 +89,10 @@ object SessionPaths {
*/
@Synchronized
fun take(): File? {
val ended = dir
val taken = dir
dir = null
return ended
if (taken != null) ended = taken
return taken
}
fun file(name: String): File? = dir?.let { File(it, name) }
@@ -78,4 +108,39 @@ object SessionPaths {
com.droiddeck.launcher.core.FileUtils.delete(ended)
}
}
/**
* What a session's folder is named for: Steam, the desktop, or the program started from the
* rail by its own name (`2026-10-01-02-retroarch`).
*/
fun label(mode: String, program: String?): String = when (mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(program)
?: program?.substringAfterLast('/')
?: mode
SessionService.MODE_DESKTOP -> "desktop"
else -> mode
}
/** Whether [f] is a session's folder, in either naming. */
fun isSessionFolder(f: File): Boolean =
f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name))
/**
* Oldest first: by day, then by when in the day. A folder from before the new naming sorts
* ahead of a new one from the same day, which it always predates.
*/
val chronological: Comparator<File> = compareBy { f ->
NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" }
?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" }
?: f.name
}
/** Every session folder in the places logs are written, oldest first. */
fun sessionFolders(context: Context): List<File> =
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
.sortedWith(chronological)
internal fun slug(text: String): String =
text.lowercase(Locale.US).replace(Regex("[^a-z0-9]+"), "-").trim('-').take(24).trim('-').ifEmpty { "session" }
}
@@ -153,6 +153,7 @@ class FrontEndActions(
val onBrowseFiles: (File) -> Unit = {},
val onLogs: () -> Unit,
val onShareLogs: () -> Unit = {},
val onClearLogs: () -> Unit = {},
val onOffline: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
@@ -289,6 +289,7 @@ internal fun SetupPanel(
}
ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() }
ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs)
ActionRow("Saved session logs", "The newest ${com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS} are kept", "Clear logs", a.onClearLogs)
ToggleRow(
host, "offline", "Offline mode",
s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first",
@@ -0,0 +1,50 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class SessionPathsTest {
@get:Rule val tmp = TemporaryFolder()
private fun dir(name: String) = tmp.newFolder(name)
@Test fun bothNamingsAreSessionFolders() {
assertTrue(SessionPaths.isSessionFolder(dir("2026-10-01-03-steam")))
assertTrue(SessionPaths.isSessionFolder(dir("2026-10-01-12-retroarch")))
assertTrue(SessionPaths.isSessionFolder(dir("session-20260930-180642")))
assertTrue(SessionPaths.isSessionFolder(dir("session-20260930-180642-2")))
}
@Test fun otherFoldersAndFilesAreNot() {
assertFalse(SessionPaths.isSessionFolder(dir("tools")))
assertFalse(SessionPaths.isSessionFolder(dir("backup-test")))
assertFalse(SessionPaths.isSessionFolder(dir("2026-10-01")))
assertFalse(SessionPaths.isSessionFolder(tmp.newFile("2026-10-01-01-steam.zip")))
}
@Test fun foldersSortByDayThenNumber() {
val folders = listOf(
dir("2026-10-01-10-desktop"), dir("session-20260930-180642"), dir("2026-10-01-02-steam"),
dir("session-20261001-063000"), dir("2026-09-30-01-steam"), dir("2026-10-01-01-steam"),
)
assertEquals(
listOf(
"session-20260930-180642", "2026-09-30-01-steam", "session-20261001-063000",
"2026-10-01-01-steam", "2026-10-01-02-steam", "2026-10-01-10-desktop",
),
folders.sortedWith(SessionPaths.chronological).map { it.name },
)
}
@Test fun labelsBecomeShortLowercaseWords() {
assertEquals("steam", SessionPaths.slug("steam"))
assertEquals("dolphin-emulator", SessionPaths.slug("Dolphin Emulator"))
assertEquals("org-telegram-desktop", SessionPaths.slug("org.telegram.desktop"))
assertEquals("a-very-long-program-name", SessionPaths.slug("A very long program name that keeps going"))
assertEquals("session", SessionPaths.slug("!!!"))
}
}