diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 8c82ed818..3628375b5 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 49160f70c..ddda9c7dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index d420d5771..1f8a7233d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -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/.log beside the session logs, so a problem can + * says also goes to Download/DroidDeck/tools/.log beside the session logs, so a problem can * be handed over like a session's. */ fun run(context: Context, argv: List, 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 { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index 5f8726e41..20e04141f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -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()) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt index cd7955a57..5f8f2bf96 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index 37b766df1..33267be5d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt index 485bdc457..896d08a19 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt @@ -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 = 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 = + 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" } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 74f5caaf3..e94d9edb9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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 = {}, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 697e04b3c..e7ab00989 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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", diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt new file mode 100644 index 000000000..4cc7562f2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionPathsTest.kt @@ -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("!!!")) + } +}