mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Merge pull request #122 from Droid-Deck/feat/readable-session-logs
This commit is contained in:
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("!!!"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user