diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7fa9d195a..be6f73091 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,7 +4,6 @@
-
diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
index 90a3a5d5f..350ab973c 100644
--- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
@@ -822,9 +822,6 @@ class MainActivity : ComponentActivity() {
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
- // The session's logs land in Downloads so a failed run can be handed over as a folder
- // rather than dug out of app-private storage. targetSdk 28 means the old permission still
- // grants exactly that.
val wanted = ArrayList()
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt
index 4e8b066eb..7cb4dd267 100644
--- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt
+++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt
@@ -15,6 +15,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionEvents
+import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionService
@@ -50,6 +51,7 @@ class AgentBridgeProvider : ContentProvider() {
val response = try {
when (method) {
"state" -> state(context).put("ok", true)
+ "logs" -> exportLogs(context)
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
"stop" -> stop(context)
"resume" -> resume(context)
@@ -63,7 +65,7 @@ class AgentBridgeProvider : ContentProvider() {
private fun state(context: Context): JSONObject {
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
- val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
+ val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
val session = JSONObject()
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
.put("phase", SessionState.phase.name)
@@ -101,10 +103,12 @@ class AgentBridgeProvider : ContentProvider() {
.put("session", session)
}
- private fun latestSessionDirectory(): File? {
- val parent = LinuxRuntime.debugLogDir()
- return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
- ?.maxWithOrNull(compareBy { it.lastModified() }.thenBy { it.name })
+ private fun exportLogs(context: Context): JSONObject {
+ val dir = SessionState.logDirectory ?: SessionLogShare.latest(context)
+ ?: return error("NO_SESSION_LOGS", "No session logs are available")
+ val zip = SessionLogShare.zipFolder(context, dir)
+ ?: return error("NO_SESSION_LOGS", "No diagnostic logs are available")
+ return JSONObject().put("ok", true).put("sessionId", dir.name).put("path", zip.absolutePath)
}
private fun stop(context: Context): JSONObject {
diff --git a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
index f9c03fefb..5c89aeaa2 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
@@ -36,11 +36,16 @@ object LogRedactor {
* WebAPI key is caught by [WEBAPI_KEY]. The value class excludes `<` and `>` so running this
* twice over an already-scrubbed line changes nothing.
*/
- private val SECRET_KV = Regex(
- "(?i)\\b(access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
+ private const val SECRET_KEYS =
+ "access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
"auth[_-]?ticket|ticket|sessionid|steamloginsecure|webapikey|api[_-]?key|" +
- "machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)" +
- "(\\s*[=:]\\s*|=)([^\\s\"'<>&;,]{4,})"
+ "machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret"
+ private val SECRET_KV = Regex(
+ "(?i)\\b($SECRET_KEYS)" +
+ "([\"']?\\s*[=:]\\s*[\"']?)([^\\s\"'<>&;,]{1,})"
+ )
+ private val QUOTED_SECRET = Regex(
+ "(?i)\\b($SECRET_KEYS)([\"']?\\s*[=:]\\s*)(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')"
)
/** A Steam Guard code, only where the text around it says that is what it is. */
private val GUARD_CODE = Regex(
@@ -53,6 +58,7 @@ object LogRedactor {
"(?i)\\b(jwt|token|ticket|sessionid|steamloginsecure|machineauth)\\b[\\s=:]*([A-Za-z0-9+/=_\\-]{12,})"
)
private val LONG_TOKEN = Regex("[A-Za-z0-9_\\-]{88,}")
+ private val AUTHORIZATION = Regex("(?i)(\\b(?:proxy-)?authorization[\"']?\\s*[:=]\\s*[\"']?)[^\\r\\n\"']+")
private val STEAMID64 = Regex("\\b(76561)(\\d{8})(\\d{4})\\b")
private val STEAMID3 = Regex("\\[U:1:(\\d+)]")
/** "external address 2607:..." / "external IP: 203.0.113.9" - the client stating ours. */
@@ -179,30 +185,6 @@ object LogRedactor {
return out
}
- /** A file worth scrubbing: text (no NUL in its first 4 KB) and not huge. */
- fun isText(file: java.io.File): Boolean = try {
- file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
- val buf = ByteArray(4096)
- val n = input.read(buf)
- n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
- }
- } catch (e: Exception) {
- false
- }
-
- /**
- * Bump whenever a pattern is added or tightened. A session folder records the version it was
- * scrubbed under (SessionArtifacts.SCRUBBED_TREE_MARKER), and a share copies a file as it is
- * only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
- * out.
- */
- const val RULES_VERSION = 1
-
- /** [src]'s lines, scrubbed, to [out]. */
- fun scrubTo(src: java.io.File, out: java.io.Writer) {
- src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
- }
-
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
fun redact(line: String): String {
if (line.isEmpty()) return line
@@ -211,6 +193,11 @@ object LogRedactor {
out = GUID.replace(out, "")
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}" }
out = JWT_BASE64.replace(out, "")
+ out = AUTHORIZATION.replace(out) { "${it.groupValues[1]}" }
+ out = QUOTED_SECRET.replace(out) {
+ val quote = it.groupValues[3].first()
+ "${it.groupValues[1]}${it.groupValues[2]}$quote$quote"
+ }
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}" }
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}" }
out = WEBAPI_KEY.replace(out, "")
diff --git a/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt b/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt
index 67b924ec2..a76963508 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/SessionLogCapture.kt
@@ -49,7 +49,7 @@ object SessionLogCapture {
) { line ->
synchronized(this) {
try {
- writer?.apply { write(line); newLine(); flush() }
+ if (writer === out) out.apply { write(line); newLine(); flush() }
} catch (e: Exception) {
// A full or unmounted card must not take the session with it.
}
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
index 9f9361ad7..924b5dd81 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
@@ -1020,7 +1020,6 @@ fun FileManagerScreen(
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
- File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
}
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
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 a5a653c3e..e8e919089 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
@@ -25,7 +25,7 @@ object GuestCommand {
linkDir: File? = null, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
- File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
+ File(File(LinuxRuntime.debugLogDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
} catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
index 38a33980a..d9a552567 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
@@ -44,12 +44,10 @@ public final class LinuxRuntime {
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
private static final String SYSTEM_FONTS = "/system/fonts";
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
- /** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
public static final String DEBUG_LOG_DIR = "DroidDeck";
- public static File debugLogDir() {
- return new File(android.os.Environment.getExternalStoragePublicDirectory(
- android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR);
+ public static File debugLogDir(Context context) {
+ return new File(context.getFilesDir(), "logs");
}
private LinuxRuntime() {}
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 500c2f7d3..0d1fca04d 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt
@@ -31,21 +31,9 @@ object SessionArtifacts {
/** Written last; a folder without it did not get its ending. */
const val COMPLETE_MARKER = ".complete"
- /** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
- private const val SCRUBBED_MARKER = ".scrubbed-2"
-
- /**
- * Written by a session's own ending, and only when every file in the folder, subfolders
- * included (whatever the session script copied into steam/ and droiddeck-esync/ as well as what
- * the app wrote), went through the redactor without a failure. It lists each of those files
- * with its size and modification time, under the redactor's rules version: a share copies a
- * file as it is only when all three still match ([scrubbedFiles]), and scrubs anything else -
- * a file added or changed since, a hidden one, or every file of a folder without it.
- */
- const val SCRUBBED_TREE_MARKER = ".scrubbed-tree"
-
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
fun collect(context: Context, dir: File, reason: String) {
+ SessionLogCapture.stopFor(dir)
try {
val wayland = File(dir, "wayland.log")
if (!wayland.exists()) {
@@ -54,11 +42,10 @@ object SessionArtifacts {
}
}
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
- val record = Record(dir)
- copySteamLogs(context, dir, record)
+ copySteamLogs(context, dir)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
- markScrubbed(scrubTree(record))
+ scrubTree(dir)
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
@@ -66,153 +53,52 @@ object SessionArtifacts {
}
}
- /**
- * Once, for session folders written before every file was scrubbed and before the device's own
- * addresses were (network.txt listed them; the client's IPv6 check logs "external address"
- * into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
- * A marker records it. Runs at app start with [finishAbandoned].
- */
@Synchronized
fun scrubOlder(context: Context) {
- val current = SessionPaths.current()
- val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
- SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
- } ?: return
- if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
- dirs.forEach { dir ->
- Record(dir).let { r -> scrubFolder(dir, r); File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it, r) } }
- try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
- }
- Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
+ SessionPaths.sessionFolders(context).filter {
+ it.parentFile == LinuxRuntime.debugLogDir(context) && it != SessionPaths.current() &&
+ !File(it, COMPLETE_MARKER).exists()
+ }.forEach { scrubTree(it) }
}
- /**
- * The session's own files (session.log, app.log, desktop.log, the Steam desktop client's
- * steam-desktop.log, ...) were written as they happened, unscrubbed; this pass puts every one
- * through the redactor before the folder can be shared. A file is rewritten only if a line
- * changed. steam/ was scrubbed on the way in.
- */
- /** A file's size and modification time as they stood right after it was scrubbed. */
- private class Scrubbed(val length: Long, val modified: Long)
-
- /** Files of a folder as they stood right after scrubbing, filled by [scrubTree] and [copySteamLogs]. */
- private class Record(val dir: File) {
- val files = HashMap()
- var failed = false
- }
-
- private fun scrubFolder(dir: File, record: Record) {
- dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
- if (!LogRedactor.isText(f)) return@forEach
- val done = scrubFile(dir, f)
- if (done == null) record.failed = true else record.files[f] = done
- }
- }
-
- /**
- * Puts [f] through the redactor in place. Returns its size and modification time as it then
- * stands, or null if it could not, and [f] may still hold the original. A writer still
- * appending to [f] (the app's own log capture) changes its size afterwards, and a share then
- * scrubs it again.
- */
- private fun scrubFile(dir: File, f: File): Scrubbed? {
- val tmp = File(dir, ".${f.name}.scrub")
- return try {
- val before = Scrubbed(f.length(), f.lastModified())
- tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
- val scrubbed = tmp.readBytes()
- if (scrubbed.contentEquals(f.readBytes())) {
- tmp.delete()
- // Unchanged by the redactor, and by anything else while it read.
- before.takeIf { f.length() == it.length && f.lastModified() == it.modified }
- } else {
- // Shared storage can refuse a rename; then the scrubbed bytes are written over the
- // original instead, so it never stays behind unscrubbed.
- if (!tmp.renameTo(f)) { f.writeBytes(scrubbed); tmp.delete() }
- Scrubbed(scrubbed.size.toLong(), f.lastModified()).takeIf { f.length() == it.length }
- }
- } catch (e: Exception) {
- Log.w(TAG, "could not scrub ${f.name}", e)
- tmp.delete()
- null
- }
- }
-
- /**
- * Records [record]'s folder as scrubbed - every file went through without a failure - or
- * leaves it unmarked, so the next share and the pass over older folders scrub it again.
- */
- private fun markScrubbed(record: Record) {
- val dir = record.dir
- val tree = File(dir, SCRUBBED_TREE_MARKER)
- if (record.failed) {
- tree.delete()
- Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared")
- return
- }
- try {
- File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n")
- val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
- record.files.forEach { (f, s) ->
- lines.append(s.length).append('\t').append(s.modified).append('\t')
- .append(f.relativeTo(dir).path).append('\n')
- }
- tree.writeText(lines.toString())
- } catch (e: Exception) {
- tree.delete()
- }
- }
-
- /**
- * The files of [dir] that its own ending scrubbed and that are unchanged since (same size and
- * modification time), by path relative to [dir]. Empty when the folder has no such record, or
- * one made under other redactor rules.
- */
- fun scrubbedFiles(dir: File): Set {
- val lines = try { File(dir, SCRUBBED_TREE_MARKER).takeIf { it.isFile }?.readLines() } catch (e: Exception) { null }
- ?: return emptySet()
- if (lines.firstOrNull() != "rules ${LogRedactor.RULES_VERSION}") return emptySet()
- return lines.drop(1).mapNotNull { line ->
- val parts = line.split('\t', limit = 3)
- if (parts.size != 3) return@mapNotNull null
- val f = File(dir, parts[2])
- parts[2].takeIf { f.isFile && f.length().toString() == parts[0] && f.lastModified().toString() == parts[1] }
- }.toSet()
- }
-
- /** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */
- @androidx.annotation.VisibleForTesting
- internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir)))
-
- /**
- * [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script
- * copied files there verbatim, and a Steam log the app did not copy over again (gone from the
- * runtime, or past its size limit) stayed as the script left it. Files [copySteamLogs] already
- * redacted on the way in are in [record] and left alone.
- */
- private fun scrubTree(record: Record): Record {
- record.dir.walkTopDown().filter { it.isDirectory }.forEach { scrubFolder(it, record) }
- return record
- }
-
- /** Steam's logs: redacted into steam/, never copied verbatim. What it wrote goes in [record]. */
- private fun copySteamLogs(context: Context, dir: File, record: Record) {
- val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
- if (!logs.isDirectory) return
- val out = File(dir, "steam").apply { mkdirs() }
- logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
+ internal fun scrubTree(dir: File) {
+ SessionLogFiles.candidates(dir).forEach { file ->
+ if (!SessionLogFiles.safeFile(dir, file)) return@forEach
+ var tmp: File? = null
try {
- val dst = File(out, src.name)
- dst.bufferedWriter().use { w ->
- src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
+ tmp = File.createTempFile(".redacted-", ".tmp", dir)
+ val size = file.length()
+ val modified = file.lastModified()
+ val limit = if (file.parentFile?.name == "steam") SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES
+ tmp.bufferedWriter().use { SessionLogFiles.scrubTo(dir, file, it, limit) }
+ if (file.length() == size && file.lastModified() == modified) {
+ check(tmp.renameTo(file))
}
- record.files[dst] = Scrubbed(dst.length(), dst.lastModified())
+ } catch (e: Exception) {
+ Log.w(TAG, "could not scrub ${file.name}", e)
+ } finally {
+ tmp?.delete()
+ }
+ }
+ }
+
+ private fun copySteamLogs(context: Context, dir: File) {
+ val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
+ if (!logs.isDirectory || java.nio.file.Files.isSymbolicLink(logs.toPath())) return
+ val out = File(dir, "steam")
+ if (java.nio.file.Files.isSymbolicLink(out.toPath()) || !(out.isDirectory || out.mkdirs())) return
+ SessionLogFiles.steamNames.map { File(logs, it) }.filter { it.exists() }.forEach { src ->
+ val tmp = File.createTempFile(".redacted-", ".tmp", out)
+ try {
+ tmp.bufferedWriter().use { SessionLogFiles.scrubTo(logs, src, it, SessionLogFiles.STEAM_MAX_BYTES) }
+ check(tmp.renameTo(File(out, src.name)))
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${src.name}", e)
+ } finally {
+ tmp?.delete()
}
}
- Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
}
/**
@@ -223,10 +109,10 @@ object SessionArtifacts {
*/
@Synchronized
fun finishAbandoned(context: Context) {
- val parent = LinuxRuntime.debugLogDir()
- val abandoned = parent.listFiles { f ->
- SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
- }?.sortedWith(SessionPaths.chronological) ?: return
+ SessionLogMigration.run(context)
+ val abandoned = SessionPaths.sessionFolders(context).filter {
+ it.parentFile == LinuxRuntime.debugLogDir(context) && !File(it, COMPLETE_MARKER).exists()
+ }
if (abandoned.isEmpty()) return
val current = SessionPaths.current()
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
@@ -244,11 +130,10 @@ object SessionArtifacts {
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
"session died of a crash, the entry is in there unless the device rebooted.\n"
)
- val record = Record(dir)
- if (newest) copySteamLogs(context, dir, record)
+ if (newest) copySteamLogs(context, dir)
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
- markScrubbed(scrubTree(record))
+ scrubTree(dir)
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
Log.i(TAG, "finished the abandoned session folder $dir")
} catch (e: Exception) {
@@ -268,7 +153,7 @@ object SessionArtifacts {
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) }
+ old.forEach { SessionLogFiles.deleteTree(it) }
if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}")
}
@@ -281,10 +166,11 @@ object SessionArtifacts {
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))
+ gone.forEach { SessionLogFiles.deleteTree(it) }
+ LinuxRuntime.debugLogDir(context).listFiles { f -> f.name == SessionPaths.TOOLS_DIR || f.name.matches(Regex("tools-\\d+")) }
+ ?.forEach { SessionLogFiles.deleteTree(it) }
// Before tools/, those logs sat loose beside the session folders.
- LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
+ LinuxRuntime.debugLogDir(context).listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
Log.i(TAG, "cleared ${gone.size} session folder(s)")
return gone.size
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index afc798db3..e02b729d2 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -349,26 +349,9 @@ object SessionFiles {
indent + "}",
).joinToString(newline)
- /**
- * Where the session writes its log. Downloads is the point - a failed run is handed over as a
- * folder rather than dug out of app-private storage - but the session script redirects its own
- * output there with `exec`, and a redirection a non-interactive shell cannot open ends that
- * shell. So a public directory is used only once it is proven writable; otherwise the app's
- * own files directory, which is bound into the session anyway, stands in.
- */
- fun logDirectory(context: Context): File {
- val public = LinuxRuntime.debugLogDir()
- if (public.isDirectory || public.mkdirs()) {
- val probe = File(public, ".writable")
- try {
- if (probe.createNewFile() || probe.isFile) {
- probe.delete()
- return public
- }
- } catch (ignored: Exception) {
- }
+ fun logDirectory(context: Context): File =
+ LinuxRuntime.debugLogDir(context).apply {
+ check(!java.nio.file.Files.isSymbolicLink(toPath()))
+ check(isDirectory || mkdirs())
}
- Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
- return File(context.filesDir, "logs").apply { mkdirs() }
- }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogFiles.kt
new file mode 100644
index 000000000..4231bda1f
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogFiles.kt
@@ -0,0 +1,107 @@
+package com.droiddeck.launcher.session
+
+import com.droiddeck.launcher.core.LogRedactor
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.io.Writer
+import java.nio.channels.Channels
+import java.nio.charset.CodingErrorAction
+import java.nio.file.Files
+import java.nio.file.LinkOption.NOFOLLOW_LINKS
+import java.nio.file.StandardOpenOption.READ
+
+internal object SessionLogFiles {
+ const val MAX_BYTES = 64L * 1024 * 1024
+ const val STEAM_MAX_BYTES = 8L * 1024 * 1024
+ private const val MAX_LINE_CHARS = 64 * 1024
+
+ val steamNames = setOf(
+ "bootstrap_log.txt", "connection_log.txt", "console_log.txt", "content_log.txt",
+ "controller.txt", "compat_log.txt", "cef_log.txt", "cef_log.previous.txt",
+ "shader_log.txt", "systemaudiomanager.txt", "steamwebhelper.log", "webhelper.txt",
+ "steamui_html.txt", "steamui_system.txt", "steamui_library.txt", "steamui_login.txt",
+ "remote_connections.txt", "streaming_log.txt", "video.txt", "vrclient_steam.txt"
+ )
+ private val paths = setOf(
+ "session.log", "session.log.1", "app.log", "crash.log", "crash-app.txt",
+ "wayland.log", "audio.log", "device.txt", "network.txt", "events.jsonl",
+ "desktop.log", "steam-desktop.log", "steam.log", "pad.log", "storage.log",
+ "ended-without-teardown.txt", "droiddeck-esync/launches.log",
+ "droiddeck-esync/tools.tsv", "droiddeck-esync/wanted.tsv",
+ "droiddeck-esync/prefixes.json", "droiddeck-fex/status.json", "droiddeck-fex/Config.json"
+ ) + steamNames.map { "steam/$it" }
+
+ fun candidates(folder: File): List = paths.map { File(folder, it) }
+ .filter { Files.exists(it.toPath(), NOFOLLOW_LINKS) }
+
+ fun deleteTree(file: File) {
+ if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return
+ Files.walk(file.toPath()).use { stream ->
+ stream.iterator().asSequence().toList().asReversed().forEach { Files.deleteIfExists(it) }
+ }
+ }
+
+ fun safeFile(root: File, file: File): Boolean {
+ val base = root.toPath().toAbsolutePath().normalize()
+ var path = file.toPath().toAbsolutePath().normalize()
+ if (!path.startsWith(base) || path == base) return false
+ while (path != base) {
+ if (Files.isSymbolicLink(path)) return false
+ path = path.parent ?: return false
+ }
+ return !Files.isSymbolicLink(base) && Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS)
+ }
+
+ fun scrubTo(root: File, file: File, out: Writer, maxBytes: Long = MAX_BYTES) {
+ if (!safeFile(root, file)) throw IOException("unsafe path")
+ Files.newByteChannel(file.toPath(), READ, NOFOLLOW_LINKS).use { channel ->
+ var remaining = channel.size()
+ if (remaining >= maxBytes) throw IOException("size limit")
+ val source = Channels.newInputStream(channel)
+ val snapshot = object : InputStream() {
+ override fun read(): Int {
+ val one = ByteArray(1)
+ return if (read(one, 0, 1) < 0) -1 else one[0].toInt() and 255
+ }
+
+ override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
+ if (length == 0) return 0
+ if (remaining == 0L) return -1
+ val count = source.read(bytes, offset, minOf(length.toLong(), remaining).toInt())
+ if (count < 0) throw IOException("truncated snapshot")
+ remaining -= count
+ return count
+ }
+ }
+ val decoder = Charsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ val reader = InputStreamReader(snapshot, decoder)
+ val line = StringBuilder()
+ val buffer = CharArray(4096)
+ fun writeLine() {
+ out.write(LogRedactor.redact(line.toString().removeSuffix("\r")))
+ out.write("\n")
+ line.setLength(0)
+ }
+ while (true) {
+ val count = reader.read(buffer)
+ if (count < 0) break
+ for (i in 0 until count) {
+ val char = buffer[i]
+ if (char == '\u0000') throw IOException("binary data")
+ if (char == '\n') writeLine() else {
+ if (line.length >= MAX_LINE_CHARS) throw IOException("line limit")
+ line.append(char)
+ }
+ }
+ }
+ if (line.isNotEmpty()) {
+ if (channel.size() != channel.position()) out.write("\n")
+ else writeLine()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogMigration.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogMigration.kt
new file mode 100644
index 000000000..759aa0df0
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogMigration.kt
@@ -0,0 +1,72 @@
+package com.droiddeck.launcher.session
+
+import android.content.Context
+import android.os.Environment
+import android.util.Log
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.LinkOption.NOFOLLOW_LINKS
+
+internal object SessionLogMigration {
+ fun run(context: Context) {
+ val old = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), LinuxRuntime.DEBUG_LOG_DIR)
+ val target = LinuxRuntime.debugLogDir(context)
+ if (!old.isDirectory || Files.isSymbolicLink(old.toPath())) return
+ check(target.isDirectory || target.mkdirs())
+ old.listFiles().orEmpty().filter {
+ SessionPaths.isSessionFolder(it) || it.name == SessionPaths.TOOLS_DIR ||
+ (it.isFile && it.name.endsWith(".log"))
+ }.forEach { source ->
+ try {
+ move(source, target)
+ } catch (e: Exception) {
+ Log.w("SessionLogMigration", "could not move legacy logs into private storage", e)
+ }
+ }
+ }
+
+ internal fun move(source: File, target: File) {
+ check(!Files.isSymbolicLink(target.toPath()))
+ val paths = Files.walk(source.toPath()).use { it.iterator().asSequence().toList() }
+ check(paths.all { !Files.isSymbolicLink(it) &&
+ (Files.isRegularFile(it, NOFOLLOW_LINKS) || Files.isDirectory(it, NOFOLLOW_LINKS)) })
+ var destination = File(target, source.name)
+ var number = 1
+ val stem = Regex("^session-\\d{8}-\\d{6}").find(source.name)?.value ?: source.name
+ while (Files.exists(destination.toPath(), NOFOLLOW_LINKS)) {
+ val name = if (source.isFile) "${source.nameWithoutExtension}-${number++}.${source.extension}"
+ else "$stem-${number++}"
+ destination = File(target, name)
+ }
+ if (source.renameTo(destination)) return
+ val stage = File.createTempFile(".migrating-", ".tmp", target)
+ val versions = paths.associate { it.toFile().let { file -> file to (file.length() to file.lastModified()) } }
+ try {
+ if (source.isDirectory) { check(stage.delete()); check(stage.mkdir()) }
+ paths.forEach { path ->
+ val file = path.toFile()
+ val out = if (file == source) stage else File(stage, file.relativeTo(source).path)
+ if (Files.isDirectory(path, NOFOLLOW_LINKS)) {
+ check(out.isDirectory || out.mkdirs())
+ } else {
+ check(file == source || SessionLogFiles.safeFile(source, file))
+ Files.newByteChannel(path, java.nio.file.StandardOpenOption.READ, NOFOLLOW_LINKS).use { input ->
+ out.outputStream().use { output -> java.nio.channels.Channels.newInputStream(input).copyTo(output) }
+ }
+ check(out.length() == versions.getValue(file).first)
+ }
+ }
+ check(versions.all { (file, version) -> file.length() == version.first && file.lastModified() == version.second })
+ paths.asReversed().forEach { path ->
+ val file = path.toFile()
+ val out = if (file == source) stage else File(stage, file.relativeTo(source).path)
+ check(out.setLastModified(versions.getValue(file).second))
+ }
+ check(stage.renameTo(destination))
+ paths.asReversed().forEach { Files.delete(it) }
+ } finally {
+ stage.deleteRecursively()
+ }
+ }
+}
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 10bfd3b04..db5c65660 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt
@@ -17,11 +17,6 @@ import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
-/**
- * Zips the most recent session's log folder and hands it to Android's share sheet. Works where
- * the folder could not be written to Downloads (no storage permission, or a device that refuses
- * it) and landed in the app's private files instead, which a user cannot otherwise reach.
- */
object SessionLogShare {
private val main = Handler(Looper.getMainLooper())
@@ -51,71 +46,57 @@ object SessionLogShare {
/** The newest session folder in either place logs are written, or null if there is none. */
fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull()
- /** Builds the zip (blocking). Returns null when there is no session to share. */
-
- /** Builds a zip for one specific session folder (blocking). */
+ @Synchronized
fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? {
- if (!folder.isDirectory) return null
- // The client's logs past STEAM_LOG_MAX_BYTES stay out, as they do from the app's own copy: a
- // CEF debug log or an old *.previous.txt reached 17 MB, every share scrubbed them line by
- // line, and the share sheet took ten seconds to appear.
- val files = folder.walkTopDown().filter { it.isFile }
- .filterNot { it.parentFile?.name == "steam" && it.length() > STEAM_LOG_MAX_BYTES }.toList()
- if (files.isEmpty()) return null
- val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
- val zip = File(out, "DroidDeck-${folder.name}.zip")
- // Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
- // session pass yet, and the redactor changes nothing in a line that is already clean.
+ if (!folder.isDirectory || java.nio.file.Files.isSymbolicLink(folder.toPath())) return null
+ val files = SessionLogFiles.candidates(folder).map { Triple(folder, it, it.relativeTo(folder).invariantSeparatorsPath) }
+ val liveRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
+ val live = if (folder == SessionPaths.current()) SessionLogFiles.steamNames
+ .filter { name -> files.none { it.third == "steam/$name" } }
+ .map { File(liveRoot, it) }.filter { java.nio.file.Files.exists(it.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) }
+ .map { Triple(liveRoot, it, "steam/${it.name}") } else emptyList()
+ val sources = files + live
+ if (sources.isEmpty()) return null
+ val out = File(context.cacheDir, "shared-logs").apply { SessionLogFiles.deleteTree(this); check(mkdirs()) }
+ val name = folder.name.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ val zip = File(out, "DroidDeck-$name.zip")
+ val partial = File(out, ".export")
+ val stage = File.createTempFile("redacted-", ".tmp", context.cacheDir)
+ val omitted = ArrayList()
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
- // A finished folder was scrubbed whole when it ended. A file its record lists, unchanged
- // since, goes in as it is; anything else (added or changed since, hidden, or any file of a
- // session still running) is scrubbed again. Scrubbing tens of MB of the client's logs on
- // every share kept the share sheet from appearing for ten seconds or more.
- val scrubbed = SessionArtifacts.scrubbedFiles(folder)
- val live = liveSteamLogs(context, folder)
- val total = (files + live).sumOf { it.length() }.coerceAtLeast(1L)
- var done = 0L
- var shown = -1
- fun advance(f: File) {
- done += f.length()
- val percent = (done * 100 / total).toInt()
- if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
+ try {
+ ZipOutputStream(partial.outputStream().buffered()).use { z ->
+ sources.forEachIndexed { index, (root, src, relative) ->
+ val limit = if (relative.startsWith("steam/")) SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES
+ val failure = runCatching {
+ stage.bufferedWriter().use { SessionLogFiles.scrubTo(root, src, it, limit) }
+ }.exceptionOrNull()
+ if (failure == null) {
+ z.putNextEntry(ZipEntry("$name/$relative"))
+ stage.inputStream().use { it.copyTo(z) }
+ z.closeEntry()
+ } else {
+ omitted += "$relative: omitted because it could not be safely sanitized."
+ }
+ onProgress?.invoke((index + 1).toFloat() / sources.size)
+ }
+ if (omitted.isNotEmpty()) {
+ z.putNextEntry(ZipEntry("$name/omitted.txt"))
+ z.write((omitted.joinToString("\n") + "\n").toByteArray(Charsets.UTF_8))
+ z.closeEntry()
+ }
+ }
+ check(partial.renameTo(zip))
+ return zip
+ } catch (e: Exception) {
+ partial.delete()
+ zip.delete()
+ throw e
+ } finally {
+ stage.delete()
}
- ZipOutputStream(zip.outputStream().buffered()).use { z ->
- files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) }
- live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) }
- }
- return zip
}
- private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) {
- z.putNextEntry(ZipEntry(name))
- if (!scrubbed && LogRedactor.isText(f)) {
- val w = z.bufferedWriter()
- LogRedactor.scrubTo(f, w)
- w.flush()
- } else {
- f.inputStream().use { it.copyTo(z) }
- }
- z.closeEntry()
- }
-
- /**
- * The client's own logs as they stand, for a session shared while it runs. The session script
- * copies them into steam/ only as it exits, so a zip made from the drawer had none - and
- * controller.txt (which pad the client opened, the touch mode it set) is what a controller or
- * touch report needs most. Only for the running session: an older folder would get this
- * session's logs. The same files the script copies; nothing holding credentials is in logs/.
- */
- private fun liveSteamLogs(context: Context, folder: File): List {
- if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
- val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
- return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
- }
-
- /** The client's content and bootstrap logs grow large over months and say nothing about a session. */
- private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
-
fun shareIntent(context: Context, zip: File): Intent {
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
val send = Intent(Intent.ACTION_SEND)
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 896d08a19..5cc9a526e 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt
@@ -7,29 +7,6 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
-/**
- * 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/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.
- */
object SessionPaths {
private const val TAG = "SessionPaths"
@@ -50,12 +27,6 @@ object SessionPaths {
@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, label: String? = null): File {
dir?.let { if (it.isDirectory) return it }
@@ -104,8 +75,8 @@ object SessionPaths {
// next session may have claimed its own by the time the last one's collector gets here.
if (dir == ended) dir = null
// A folder kept in the cache was never meant to outlive its session.
- if (ended != null && ended.path.startsWith(context.cacheDir.path)) {
- com.droiddeck.launcher.core.FileUtils.delete(ended)
+ if (ended.parentFile == File(context.cacheDir, "session-logs")) {
+ SessionLogFiles.deleteTree(ended)
}
}
@@ -123,22 +94,28 @@ object SessionPaths {
/** 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))
+ f.isDirectory && !java.nio.file.Files.isSymbolicLink(f.toPath()) &&
+ (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 ->
+ 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
- }
+ }.thenBy { it.lastModified() }
/** 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() }
+ listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(context),
+ File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS),
+ com.droiddeck.launcher.runtime.LinuxRuntime.DEBUG_LOG_DIR))
+ .flatMap { parent ->
+ if (java.nio.file.Files.isSymbolicLink(parent.toPath())) emptyList()
+ else parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList()
+ }
.sortedWith(chronological)
internal fun slug(text: String): String =
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index 96d02cee6..6c81b2a2c 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -277,9 +277,6 @@ class SessionService : Service() {
try {
SessionArtifacts.collect(this, dir, "session stopped")
} finally {
- // Last, so everything above is in the file it is about - and only this session's:
- // a session that replaced this one may already own the capture and the folder.
- SessionLogCapture.stopFor(dir)
SessionPaths.release(this, dir)
}
}
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 797d3e358..97bad0d21 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -640,7 +640,7 @@
Elegir
Apps de Linux (beta)
Tienda Flathub
- En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck.
+ En el menú. Algunas apps pueden no iniciarse.
Instala apps y juegos de Linux desde Flathub
Archivos
Explorar y gestionar archivos
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 61e76e8f7..670378ced 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -534,7 +534,7 @@
Choisir
Applications Linux (bêta)
Boutique Flathub
- Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck.
+ Affiché dans le menu. Certaines applications peuvent ne pas se lancer.
Installer des applications et des jeux Linux depuis Flathub
Fichiers
Parcourir et gérer les fichiers
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 12f3435ab..23d1f4841 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -638,7 +638,7 @@
選択
Linuxアプリ(ベータ版)
Flathubストア
- メニューに表示されます。一部のアプリは起動できない場合があります。ログはDownload/DroidDeckにあります。
+ メニューに表示されます。一部のアプリは起動できない場合があります。
FlathubからLinuxアプリとゲームをインストール
ファイル
ファイルの閲覧と管理
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index ee6c278dc..4e703b3f1 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -638,7 +638,7 @@
선택
Linux 앱(베타)
Flathub 스토어
- 메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다. 로그는 Download/DroidDeck에 있습니다.
+ 메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다.
Flathub에서 Linux 앱 및 게임 설치
파일
파일 탐색 및 관리
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index e0a1a5b04..a1fc2f5ec 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -536,7 +536,7 @@
Выбрать
Приложения Linux (бета)
Магазин Flathub
- Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck.
+ Показывается в меню. Некоторые приложения могут не запускаться.
Устанавливать приложения и игры Linux из Flathub
Файлы
Просмотр и управление файлами
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 6139fecb8..e784e6f81 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -638,7 +638,7 @@
选择
Linux 应用(测试版)
Flathub 商店
- 显示在菜单中。部分应用可能无法启动;日志位于 Download/DroidDeck。
+ 显示在菜单中。部分应用可能无法启动。
从 Flathub 安装 Linux 应用和游戏
文件
浏览和管理文件
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 6fda24b77..6c8f9519a 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -638,7 +638,7 @@
選擇
Linux 應用程式(測試版)
Flathub 商店
- 顯示在選單中。部分應用程式可能無法啟動;記錄位於 Download/DroidDeck。
+ 顯示在選單中。部分應用程式可能無法啟動。
從 Flathub 安裝 Linux 應用程式和遊戲
檔案
瀏覽及管理檔案
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 6af7eb6cf..55bd98848 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -638,7 +638,7 @@
選擇
Linux 應用程式(測試版)
Flathub 商店
- 顯示在選單中。部分應用程式可能無法啟動;日誌位於 Download/DroidDeck。
+ 顯示在選單中。部分應用程式可能無法啟動。
從 Flathub 安裝 Linux 應用程式和遊戲
檔案
瀏覽和管理檔案
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 71799ae61..2b169810e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -643,7 +643,7 @@
Choose
Linux apps (beta)
Flathub Store
- In the menu. Some apps may not start; logs are in Download/DroidDeck.
+ In the menu. Some apps may not start.
Install Linux apps and games from Flathub
Files
Browse and manage files
diff --git a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt
index 88f2a8703..542fedec0 100644
--- a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt
@@ -55,4 +55,37 @@ class LogRedactorTest {
assertEquals(once, LogRedactor.redact(once))
}
}
+ @Test fun quotedAndShortSecretsAreRedacted() {
+ for (line in listOf("\"access_token\": \"short-secret\"", "\"password\"=\"abc\"", "pwd=x")) {
+ val out = LogRedactor.redact(line)
+ assertTrue(out.contains(""))
+ assertEquals(out, LogRedactor.redact(out))
+ }
+ }
+
+ @Test fun authorizationHeadersAreRedacted() {
+ for (line in listOf("Authorization: Bearer secret-value", "Proxy-Authorization: Basic dXNlcjpwYXNz",
+ "\"Authorization\":\"Bearer secret-value\"")) {
+ val out = LogRedactor.redact(line)
+ assertFalse(out.contains("secret-value"))
+ assertFalse(out.contains("dXNlcjpwYXNz"))
+ assertTrue(out.contains(""))
+ assertEquals(out, LogRedactor.redact(out))
+ }
+ }
+
+ @Test fun quotedSecretsWithSpacesAndEscapedQuotesAreFullyWithheld() {
+ val lines = listOf(
+ "password=\"my secret phrase\"",
+ "password='my secret phrase'",
+ "\"access_token\":\"my \\\"secret\\\" phrase\"",
+ )
+ for (line in lines) {
+ val out = LogRedactor.redact(line)
+ assertFalse(out.contains("phrase"))
+ assertFalse(out.contains("secret"))
+ assertTrue(out.contains(""))
+ assertEquals(out, LogRedactor.redact(out))
+ }
+ }
}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogMigrationTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogMigrationTest.kt
new file mode 100644
index 000000000..0f216f281
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogMigrationTest.kt
@@ -0,0 +1,100 @@
+package com.droiddeck.launcher.session
+
+import org.junit.Assert.*
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+import java.nio.file.Files
+
+class SessionLogMigrationTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ private fun source(name: String = "2026-10-06-01-steam"): File = tmp.newFolder(name).apply {
+ File(this, "steam").mkdir()
+ File(this, "session.log").writeText("Using JWT 25484942796017334\n")
+ File(this, "steam/console_log.txt").writeText("password=private\n")
+ File(this, ".complete").writeText("done")
+ }
+
+ @Test fun legacySessionsMoveIntoPrivateStorage() {
+ val source = source()
+ val target = tmp.newFolder("private")
+ SessionLogMigration.move(source, target)
+ assertFalse(source.exists())
+ val moved = File(target, source.name)
+ assertTrue(SessionPaths.isSessionFolder(moved))
+ assertTrue(File(moved, "steam/console_log.txt").readText().contains("password=private"))
+ assertTrue(File(moved, ".complete").isFile)
+ }
+
+ @Test fun crossFilesystemFallbackCopiesEverythingBeforeRemovingTheOriginal() {
+ val original = source()
+ val source = object : File(original.path) {
+ override fun renameTo(dest: File): Boolean = false
+ }
+ val target = tmp.newFolder("private")
+ SessionLogMigration.move(source, target)
+ assertFalse(original.exists())
+ assertEquals("Using JWT 25484942796017334\n", File(target, "${original.name}/session.log").readText())
+ assertEquals(listOf(original.name), target.list()!!.toList())
+ }
+
+ @Test fun aCopyFailurePreservesAllOriginalFiles() {
+ val original = source()
+ val source = object : File(original.path) {
+ override fun renameTo(dest: File): Boolean = false
+ }
+ val target = tmp.newFolder("private")
+ target.setWritable(false)
+ try {
+ assertThrows(java.io.IOException::class.java) { SessionLogMigration.move(source, target) }
+ } finally {
+ target.setWritable(true)
+ }
+ assertTrue(File(original, "session.log").isFile)
+ assertTrue(File(original, "steam/console_log.txt").isFile)
+ assertTrue(target.list()!!.isEmpty())
+ }
+
+ @Test fun collisionsPreserveBothSessionsAndKeepLegacyNamesRecognizable() {
+ val original = source("session-20260930-180642-2")
+ val target = tmp.newFolder("private")
+ File(target, original.name).mkdir()
+ SessionLogMigration.move(original, target)
+ assertEquals(2, target.listFiles()!!.size)
+ assertTrue(target.listFiles()!!.all { SessionPaths.isSessionFolder(it) })
+ }
+
+ @Test fun linksAreRejectedWithoutReadingOrDeletingTheirTargets() {
+ val source = source()
+ val outside = tmp.newFile("outside").apply { writeText("outside data") }
+ Files.createSymbolicLink(File(source, "steam/connection_log.txt").toPath(), outside.toPath())
+ val target = tmp.newFolder("private")
+ assertThrows(IllegalStateException::class.java) { SessionLogMigration.move(source, target) }
+ assertTrue(File(source, "session.log").exists())
+ assertTrue(target.list()!!.isEmpty())
+ assertEquals("outside data", outside.readText())
+ }
+
+ @Test fun cleanupDoesNotFollowNestedDirectoryLinks() {
+ val source = source()
+ val outside = tmp.newFolder("outside")
+ val preserved = File(outside, "session.log").apply { writeText("outside data") }
+ Files.createSymbolicLink(File(source, "linked").toPath(), outside.toPath())
+ SessionLogFiles.deleteTree(source)
+ assertFalse(source.exists())
+ assertEquals("outside data", preserved.readText())
+ }
+ @Test fun aCopiedCollisionKeepsTheNewerSessionAsTheLatest() {
+ val original = source()
+ original.setLastModified(1000L)
+ val source = object : File(original.path) {
+ override fun renameTo(dest: File): Boolean = false
+ }
+ val target = tmp.newFolder("private")
+ val newer = File(target, original.name).apply { mkdir(); setLastModified(2000L) }
+ SessionLogMigration.move(source, target)
+ assertEquals(newer, target.listFiles()!!.sortedWith(SessionPaths.chronological).last())
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt
index ea9250520..70849befe 100644
--- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareTest.kt
@@ -1,9 +1,7 @@
package com.droiddeck.launcher.session
-import com.droiddeck.launcher.core.LogRedactor
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
@@ -12,114 +10,197 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
+import java.io.RandomAccessFile
+import java.io.StringWriter
+import java.io.Writer
+import java.nio.file.Files
import java.util.zip.ZipFile
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SessionLogShareTest {
@get:Rule val tmp = TemporaryFolder()
+ private val context get() = RuntimeEnvironment.getApplication()
+ private val secret = "Using JWT 25484942796017334 sessionid=abcdef123456 password=secret123"
+ private val leaked = listOf("25484942796017334", "abcdef123456", "secret123")
- private val secret = "GET /x?sessionid=abcdef123456 200"
- private val leaked = "abcdef123456"
+ private fun write(folder: File, path: String, text: String = "$secret\n"): File =
+ File(folder, path).apply { parentFile!!.mkdirs(); writeText(text) }
private fun zip(folder: File): Map =
- ZipFile(SessionLogShare.zipFolder(RuntimeEnvironment.getApplication(), folder)!!).use { z ->
+ ZipFile(SessionLogShare.zipFolder(context, folder)!!).use { z ->
z.entries().toList().associate { it.name.substringAfter('/') to z.getInputStream(it).bufferedReader().readText() }
}
- /** A record as the session's ending writes it, listing [files] as they stand now. */
- private fun record(folder: File, vararg files: String, rules: Int = LogRedactor.RULES_VERSION) {
- File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).writeText(
- "rules $rules\n" + files.joinToString("") { val f = File(folder, it); "${f.length()}\t${f.lastModified()}\t$it\n" })
+ private fun assertSafe(files: Map) {
+ files.forEach { (name, text) -> leaked.forEach { assertFalse(name, text.contains(it)) } }
}
- @Test fun aRecordedUnchangedFileGoesInAsItIs() {
+ @Test fun workingLogsAndCommandLogsUsePrivateStorage() {
+ assertEquals(File(context.filesDir, "logs"), SessionFiles.logDirectory(context))
+ assertEquals(SessionFiles.logDirectory(context), LinuxRuntime.debugLogDir(context))
+ assertTrue(File(LinuxRuntime.debugLogDir(context), SessionPaths.TOOLS_DIR).path.startsWith(context.filesDir.path + "/"))
+ val dir = SessionPaths.beginOrCurrent(context, "privacy-test")
+ try {
+ assertTrue(dir.path.startsWith(context.filesDir.path + "/") || dir.path.startsWith(context.cacheDir.path + "/"))
+ } finally {
+ SessionPaths.take()
+ SessionPaths.release(context, dir)
+ dir.deleteRecursively()
+ }
+ }
+
+ @Test fun forgedAndStaleScrubRecordsNeverBypassRedaction() {
val folder = tmp.newFolder("2026-10-06-01-steam")
- // Stands in for a file the ending scrubbed; the share must not pass it through again.
- File(folder, "session.log").writeText("$secret\n")
- record(folder, "session.log")
- assertTrue(zip(folder).getValue("session.log").contains(leaked))
+ val file = write(folder, "session.log")
+ write(folder, ".scrubbed-tree", "rules 1\n${file.length()}\t${file.lastModified()}\tsession.log\n")
+ write(folder, ".complete", "done")
+ assertSafe(zip(folder))
+ val modified = file.lastModified()
+ file.writeText("$secret\n")
+ file.setLastModified(modified)
+ assertSafe(zip(folder))
+ assertEquals(setOf("session.log"), zip(folder).keys)
}
- @Test fun anythingTheRecordDoesNotVouchForIsScrubbed() {
+ @Test fun allowlistIncludesDiagnosticsAndExcludesCredentialStoresAndUnknownFiles() {
val folder = tmp.newFolder("2026-10-06-02-steam")
- File(folder, "app.log").writeText("ok\n")
- File(folder, "session.log").writeText("ok\n")
- File(folder, "steam/deep").mkdirs()
- File(folder, "steam/deep/nested.txt").writeText("$secret\n")
- File(folder, ".hidden.txt").writeText("$secret\n")
- record(folder, "app.log", "session.log")
- // Appended to after the ending (the app's log capture), and touched with an older time.
- File(folder, "app.log").appendText("$secret\n")
- File(folder, "session.log").apply { writeText("$secret\n".padEnd(3, ' ')); setLastModified(1_000L) }
- File(folder, "events.jsonl").writeText("$secret\n")
-
- val out = zip(folder)
- for (name in listOf("app.log", "session.log", "steam/deep/nested.txt", ".hidden.txt", "events.jsonl"))
- assertFalse(name, out.getValue(name).contains(leaked))
+ val allowed = listOf("session.log", "app.log", "crash-app.txt", "events.jsonl",
+ "steam/console_log.txt", "droiddeck-esync/launches.log", "droiddeck-fex/status.json")
+ val excluded = listOf(".hidden.txt", ".session.log.scrub", "steam/deep/nested.txt",
+ "steam/loginusers.vdf", "steam/config.vdf", "ssfn123", "arbitrary.txt",
+ "steam/token.txt", "droiddeck-fex/config/secrets.json")
+ (allowed + excluded).forEach { write(folder, it) }
+ val files = zip(folder)
+ assertEquals(allowed.toSet(), files.keys)
+ assertSafe(files)
}
- @Test fun aRecordUnderOtherRedactorRulesVouchesForNothing() {
- val folder = tmp.newFolder("2026-10-06-03-steam")
- File(folder, "session.log").writeText("$secret\n")
- record(folder, "session.log", rules = LogRedactor.RULES_VERSION - 1)
- assertFalse(zip(folder).getValue("session.log").contains(leaked))
+ @Test fun oversizedLiveAndFinishedLogsAreOmittedWithoutRawFallback() {
+ for (finished in listOf(false, true)) for (size in listOf(SessionLogFiles.MAX_BYTES, SessionLogFiles.MAX_BYTES + 1)) {
+ val folder = tmp.newFolder("2026-10-06-${if (finished) "03" else "04"}-steam-$size")
+ write(folder, "app.log")
+ val large = write(folder, "session.log")
+ RandomAccessFile(large, "rw").use { it.setLength(size) }
+ if (finished) write(folder, ".complete", "done")
+ val files = zip(folder)
+ assertFalse(files.containsKey("session.log"))
+ assertTrue(files.getValue("omitted.txt").contains("session.log"))
+ assertSafe(files)
+ }
}
- @Test fun withoutARecordEveryFileIsScrubbed() {
- val folder = tmp.newFolder("2026-10-06-04-steam")
- File(folder, "session.log").writeText("$secret\n")
- File(folder, "steam").mkdirs()
- File(folder, "steam/console_log.txt").writeText("$secret\n")
- val out = zip(folder)
- assertFalse(out.getValue("session.log").contains(leaked))
- assertFalse(out.getValue("steam/console_log.txt").contains(leaked))
- }
-
- @Test fun theEndingScrubsEveryFolderAndRecordsWhatItScrubbed() {
+ @Test fun binaryDataPastTheProbeMalformedUtf8AndLongLinesAreOmitted() {
val folder = tmp.newFolder("2026-10-06-05-steam")
- File(folder, "session.log").writeText("$secret\n")
- File(folder, "droiddeck-esync").mkdirs()
- File(folder, "droiddeck-esync/launches.log").writeText("$secret\n")
- File(folder, "steam/deep").mkdirs()
- File(folder, "steam/deep/nested.txt").writeText("$secret\n")
-
- SessionArtifacts.scrubAndMark(folder)
-
- for (name in listOf("session.log", "droiddeck-esync/launches.log", "steam/deep/nested.txt"))
- assertFalse(name, File(folder, name).readText().contains(leaked))
- assertEquals(setOf("session.log", "droiddeck-esync/launches.log", "steam/deep/nested.txt"),
- SessionArtifacts.scrubbedFiles(folder))
- assertTrue(zip(folder).values.none { it.contains(leaked) })
+ write(folder, "session.log", "safe\n".repeat(2000) + "$secret\n\u0000")
+ File(folder, "app.log").writeBytes(byteArrayOf(0xc3.toByte(), 0x28))
+ write(folder, "crash.log", "x".repeat(65537) + secret)
+ val files = zip(folder)
+ assertEquals(setOf("omitted.txt"), files.keys)
+ assertSafe(files)
}
- @Test fun aFileThatCouldNotBeScrubbedLeavesTheFolderUnrecorded() {
+ @Test fun symlinkFilesAndDirectoriesCannotExportOutsideData() {
val folder = tmp.newFolder("2026-10-06-06-steam")
- File(folder, "session.log").writeText("$secret\n")
- // A folder the scrubbed copy cannot be written into: the scrub fails, the original stays.
+ val outside = tmp.newFolder("outside")
+ val target = write(outside, "console_log.txt", "outside data\n")
+ Files.createSymbolicLink(File(folder, "session.log").toPath(), target.toPath())
+ Files.createSymbolicLink(File(folder, "steam").toPath(), outside.toPath())
+ val files = zip(folder)
+ assertEquals(setOf("omitted.txt"), files.keys)
+ assertTrue(files.values.none { it.contains("outside data") })
+ val alias = File(tmp.root, "2026-10-06-07-steam")
+ Files.createSymbolicLink(alias.toPath(), folder.toPath())
+ assertFalse(SessionPaths.isSessionFolder(alias))
+ assertNull(SessionLogShare.zipFolder(context, alias))
+ }
+
+ @Test fun failedTeardownScrubbingStillRedactsExports() {
+ val folder = tmp.newFolder("2026-10-06-08-steam")
+ val file = write(folder, "session.log")
folder.setWritable(false)
try {
- SessionArtifacts.scrubAndMark(folder)
+ SessionArtifacts.scrubTree(folder)
} finally {
folder.setWritable(true)
}
- assertTrue(File(folder, "session.log").readText().contains(leaked))
- assertFalse(File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).exists())
- assertTrue(SessionArtifacts.scrubbedFiles(folder).isEmpty())
- assertTrue(zip(folder).values.none { it.contains(leaked) })
+ assertTrue(file.readText().contains(leaked.first()))
+ assertSafe(zip(folder))
}
- @Test fun steamLogsPastTheSizeLimitStayOut() {
- val folder = tmp.newFolder("2026-10-06-07-steam")
- File(folder, "session.log").writeText("ok\n")
- File(folder, "steam").mkdirs()
- File(folder, "steam/console_log.txt").writeText("ok\n")
- File(folder, "steam/cef_log.previous.txt").outputStream().use { out ->
- val line = ByteArray(1024) { 'x'.code.toByte() }.also { it[it.size - 1] = '\n'.code.toByte() }
- repeat(9 * 1024) { out.write(line) }
+ @Test fun unreadableAllowedFilesAreOmitted() {
+ val folder = tmp.newFolder("2026-10-06-09-steam")
+ val file = write(folder, "session.log")
+ file.setReadable(false)
+ try {
+ val files = zip(folder)
+ assertEquals(setOf("omitted.txt"), files.keys)
+ assertSafe(files)
+ } finally {
+ file.setReadable(true)
}
- val names = zip(folder).keys
- assertTrue("steam/console_log.txt" in names)
- assertFalse("steam/cef_log.previous.txt" in names)
+ }
+
+ @Test fun successfulTeardownScrubsNestedDiagnosticsWithoutRecords() {
+ val folder = tmp.newFolder("2026-10-06-10-steam")
+ val paths = listOf("session.log", "crash-app.txt", "droiddeck-esync/launches.log", "steam/console_log.txt")
+ paths.forEach { write(folder, it) }
+ SessionArtifacts.scrubTree(folder)
+ paths.forEach { path -> leaked.forEach { assertFalse(File(folder, path).readText().contains(it)) } }
+ assertSafe(zip(folder))
+ assertTrue(folder.walkTopDown().none { it.name.startsWith(".redacted-") })
+ }
+
+ @Test fun steamLogsAtTheSizeLimitStayOut() {
+ val folder = tmp.newFolder("2026-10-06-11-steam")
+ write(folder, "session.log")
+ val file = write(folder, "steam/console_log.txt")
+ RandomAccessFile(file, "rw").use { it.setLength(SessionLogFiles.STEAM_MAX_BYTES) }
+ val files = zip(folder)
+ assertFalse(files.containsKey("steam/console_log.txt"))
+ assertTrue(files.containsKey("omitted.txt"))
+ assertSafe(files)
+ }
+
+ @Test fun aGrowingFileIsLimitedToItsOpeningSnapshot() {
+ val folder = tmp.newFolder("2026-10-06-12-steam")
+ val file = write(folder, "session.log", "safe\n".repeat(2000) + "$secret\nunfinished")
+ val output = StringWriter()
+ var appended = false
+ val writer = object : Writer() {
+ override fun write(chars: CharArray, offset: Int, length: Int) {
+ if (!appended) { appended = true; file.appendText("appended-marker $secret\n") }
+ output.write(chars, offset, length)
+ }
+ override fun flush() = Unit
+ override fun close() = Unit
+ }
+ SessionLogFiles.scrubTo(folder, file, writer)
+ assertFalse(output.toString().contains("appended-marker"))
+ assertFalse(output.toString().contains("unfinished"))
+ assertTrue(output.toString().contains(""))
+ leaked.forEach { assertFalse(output.toString().contains(it)) }
+ }
+
+ @Test fun aTruncatedFileFailsInsteadOfExportingAPartialSnapshot() {
+ val folder = tmp.newFolder("2026-10-06-13-steam")
+ val file = write(folder, "session.log", "safe\n".repeat(20000))
+ val writer = object : Writer() {
+ override fun write(chars: CharArray, offset: Int, length: Int) { file.writeText("") }
+ override fun flush() = Unit
+ override fun close() = Unit
+ }
+ assertThrows(java.io.IOException::class.java) { SessionLogFiles.scrubTo(folder, file, writer) }
+ }
+
+ @Test fun anOutputFailureDoesNotLeaveAShareableArchive() {
+ val folder = tmp.newFolder("2026-10-06-14-steam")
+ write(folder, "session.log")
+ assertThrows(IllegalStateException::class.java) {
+ SessionLogShare.zipFolder(context, folder) { throw IllegalStateException("failed output") }
+ }
+ val out = File(context.cacheDir, "shared-logs")
+ assertTrue(out.listFiles().orEmpty().isEmpty())
+ assertTrue(context.cacheDir.listFiles().orEmpty().none { it.name.startsWith("redacted-") })
}
}
diff --git a/docs/agent-control.md b/docs/agent-control.md
index 77e4ce583..fdd793a60 100644
--- a/docs/agent-control.md
+++ b/docs/agent-control.md
@@ -28,7 +28,7 @@ tools/droiddeckctl start desktop
tools/droiddeckctl run /usr/bin/foo -- arg1 arg2
tools/droiddeckctl stop
tools/droiddeckctl resume
-tools/droiddeckctl logs latest ./session-artifacts
+tools/droiddeckctl logs latest ./session-logs.zip
tools/droiddeckctl screenshot ./screen.png
```
@@ -39,3 +39,5 @@ Every command writes JSON to stdout and reports its resolved ADB serial to stder
The `state` response uses schema 1 and includes the build label, runtime version, session ID and phase, mode, requested program, suspend and first-frame state, output size, guest PID, failure details, and artifact paths. Phases are `IDLE`, `PREPARING`, `INSTALLING_RUNTIME`, `STARTING_COMPOSITOR`, `STARTING_GUEST`, `STARTING_STEAM`, `READY`, `SUSPENDED`, `STOPPING`, and `FAILED`. Each session also writes `events.jsonl` alongside its existing artifacts.
`tools/deploy_local.sh` uses the same device resolver. If multiple distinct devices are connected, select one with `ADB_SERIAL` or `ANDROID_SERIAL`.
+
+`logs latest` exports the same sanitized ZIP as Share logs. Working logs stay in app-private storage; the command retrieves the ZIP through `run-as` on debug builds.
diff --git a/tools/droiddeckctl b/tools/droiddeckctl
index b73c5ac29..00277ef21 100755
--- a/tools/droiddeckctl
+++ b/tools/droiddeckctl
@@ -71,14 +71,14 @@ def _content_result(output: str) -> dict:
raise ControlError("Could not decode the agent provider response", "INVALID_PROVIDER_RESPONSE") from exc
-def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None) -> dict:
+def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None, timeout: int = 30) -> dict:
command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method]
if arg is not None:
command.extend(["--arg", arg])
if request is not None:
encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
command.extend(["--extra", f"request:s:{encoded}"])
- result = run_adb(adb, serial, *command)
+ result = run_adb(adb, serial, *command, timeout=timeout)
output = (result.stdout + result.stderr).decode(errors="replace")
response = _content_result(output)
if result.returncode != 0 and response.get("ok") is not False:
@@ -248,15 +248,18 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
if not session.get("running") and session.get("artifactsAvailable") and not session.get("artifactsComplete"):
state = wait_for_artifacts(adb, serial, state, args.timeout)
session = state.get("session", {})
- remote_dir = session.get("logDir")
- if not remote_dir or not session.get("id") or not session.get("artifactsAvailable"):
- raise ControlError("No session artifact directory is available", "NO_SESSION_LOGS")
- destination = Path(args.destination or session["id"]).expanduser().resolve()
- result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120)
+ response = provider_call(adb, serial, "logs", timeout=120)
+ if response.get("ok") is False:
+ error = response.get("error", {})
+ raise ControlError(error.get("message", "log export failed"), error.get("code", "LOG_EXPORT_FAILED"))
+ destination = Path(args.destination or f"{response['sessionId']}.zip").expanduser().resolve()
+ result = run_adb(adb, serial, "exec-out", "run-as", "com.droiddeck.launcher", "cat", response["path"], timeout=120)
if result.returncode != 0:
- detail = (result.stderr or result.stdout).decode(errors="replace").strip()
- raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED")
- return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial)
+ detail = result.stderr.decode(errors="replace").strip()
+ raise ControlError(detail or "log export failed", "LOG_PULL_FAILED")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(result.stdout)
+ return add_serial({"ok": True, "command": "logs", "sessionId": response["sessionId"], "path": str(destination)}, serial)
if args.command == "screenshot":
destination = Path(args.path).expanduser().resolve()
@@ -302,7 +305,7 @@ def build_parser() -> argparse.ArgumentParser:
stop.add_argument("--timeout", type=float, default=90)
commands.add_parser("resume", help="resume a suspended session")
- logs = commands.add_parser("logs", help="pull the latest session artifact directory")
+ logs = commands.add_parser("logs", help="export sanitized session logs as a ZIP")
logs.add_argument("which", choices=["latest"])
logs.add_argument("destination", nargs="?")
logs.add_argument("--timeout", type=float, default=90)
diff --git a/tools/tests/test_log_export.py b/tools/tests/test_log_export.py
new file mode 100644
index 000000000..1455567cb
--- /dev/null
+++ b/tools/tests/test_log_export.py
@@ -0,0 +1,51 @@
+import argparse
+import importlib.machinery
+import importlib.util
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+TOOLS = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(TOOLS))
+loader = importlib.machinery.SourceFileLoader("droiddeckctl", str(TOOLS / "droiddeckctl"))
+spec = importlib.util.spec_from_loader(loader.name, loader)
+ctl = importlib.util.module_from_spec(spec)
+loader.exec_module(ctl)
+
+
+class LogExportTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.destination = Path(self.tmp.name) / "session.zip"
+ self.args = argparse.Namespace(command="logs", timeout=90, destination=str(self.destination))
+ self.state = {"session": {"running": True, "id": "session1", "artifactsAvailable": True}}
+
+ def test_logs_fetch_only_the_provider_sanitized_zip(self):
+ response = {"ok": True, "sessionId": "session1", "path": "/data/user/0/com.droiddeck.launcher/cache/shared-logs/session.zip"}
+ result = subprocess.CompletedProcess([], 0, b"sanitized ZIP", b"")
+ with patch.object(ctl, "get_state", return_value=self.state), patch.object(ctl, "provider_call", return_value=response) as provider, patch.object(ctl, "run_adb", return_value=result) as adb:
+ output = ctl.execute(self.args, "adb", "device")
+ provider.assert_called_once_with("adb", "device", "logs", timeout=120)
+ adb.assert_called_once_with("adb", "device", "exec-out", "run-as", "com.droiddeck.launcher", "cat", response["path"], timeout=120)
+ self.assertEqual(b"sanitized ZIP", self.destination.read_bytes())
+ self.assertEqual(str(self.destination.resolve()), output["path"])
+
+ def test_export_failure_never_falls_back_to_pulling_raw_logs(self):
+ response = {"ok": False, "error": {"code": "NO_SESSION_LOGS", "message": "unavailable"}}
+ with patch.object(ctl, "get_state", return_value=self.state), patch.object(ctl, "provider_call", return_value=response), patch.object(ctl, "run_adb") as adb:
+ with self.assertRaises(ctl.ControlError):
+ ctl.execute(self.args, "adb", "device")
+ adb.assert_not_called()
+ self.assertFalse(self.destination.exists())
+
+ def test_failed_retrieval_does_not_write_a_partial_zip(self):
+ response = {"ok": True, "sessionId": "session1", "path": "/private/session.zip"}
+ result = subprocess.CompletedProcess([], 1, b"partial data", b"read failure")
+ with patch.object(ctl, "get_state", return_value=self.state), patch.object(ctl, "provider_call", return_value=response), patch.object(ctl, "run_adb", return_value=result):
+ with self.assertRaises(ctl.ControlError):
+ ctl.execute(self.args, "adb", "device")
+ self.assertFalse(self.destination.exists())