Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx ef261d9724 double down on PD 2026-10-07 17:18:26 -04:00
30 changed files with 689 additions and 432 deletions

No files matched your search

-1
View File
@@ -4,7 +4,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Session logs go to Downloads so a failed run can be handed over as one folder. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -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<String>()
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
@@ -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<File> { 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 {
@@ -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, "<redacted:guid>")
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
out = JWT_BASE64.replace(out, "<redacted:jwt>")
out = AUTHORIZATION.replace(out) { "${it.groupValues[1]}<redacted:authorization>" }
out = QUOTED_SECRET.replace(out) {
val quote = it.groupValues[3].first()
"${it.groupValues[1]}${it.groupValues[2]}$quote<redacted:token>$quote"
}
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
out = WEBAPI_KEY.replace(out, "<redacted:key>")
@@ -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.
}
@@ -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() } }
@@ -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(" ")}")
@@ -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() {}
@@ -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<File, Scrubbed>()
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<String> {
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
}
@@ -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() }
}
}
@@ -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<File> = 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("<incomplete live line withheld>\n")
else writeLine()
}
}
}
}
@@ -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()
}
}
}
@@ -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<String>()
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<File> {
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)
@@ -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<File> = compareBy { f ->
val chronological: Comparator<File> = compareBy<File> { 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<File> =
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 =
@@ -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)
}
}
+1 -1
View File
@@ -640,7 +640,7 @@
<string name="setup_choose">Elegir</string>
<string name="setup_linux_apps">Apps de Linux (beta)</string>
<string name="setup_store">Tienda Flathub</string>
<string name="setup_store_on">En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck.</string>
<string name="setup_store_on">En el menú. Algunas apps pueden no iniciarse.</string>
<string name="setup_store_off">Instala apps y juegos de Linux desde Flathub</string>
<string name="setup_tool_files">Archivos</string>
<string name="setup_tool_files_hint">Explorar y gestionar archivos</string>
+1 -1
View File
@@ -534,7 +534,7 @@
<string name="setup_choose">Choisir</string>
<string name="setup_linux_apps">Applications Linux (bêta)</string>
<string name="setup_store">Boutique Flathub</string>
<string name="setup_store_on">Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck.</string>
<string name="setup_store_on">Affiché dans le menu. Certaines applications peuvent ne pas se lancer.</string>
<string name="setup_store_off">Installer des applications et des jeux Linux depuis Flathub</string>
<string name="setup_tool_files">Fichiers</string>
<string name="setup_tool_files_hint">Parcourir et gérer les fichiers</string>
+1 -1
View File
@@ -638,7 +638,7 @@
<string name="setup_choose">選択</string>
<string name="setup_linux_apps">Linuxアプリ(ベータ版)</string>
<string name="setup_store">Flathubストア</string>
<string name="setup_store_on">メニューに表示されます。一部のアプリは起動できない場合があります。ログはDownload/DroidDeckにあります。</string>
<string name="setup_store_on">メニューに表示されます。一部のアプリは起動できない場合があります。</string>
<string name="setup_store_off">FlathubからLinuxアプリとゲームをインストール</string>
<string name="setup_tool_files">ファイル</string>
<string name="setup_tool_files_hint">ファイルの閲覧と管理</string>
+1 -1
View File
@@ -638,7 +638,7 @@
<string name="setup_choose">선택</string>
<string name="setup_linux_apps">Linux 앱(베타)</string>
<string name="setup_store">Flathub 스토어</string>
<string name="setup_store_on">메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다. 로그는 Download/DroidDeck에 있습니다.</string>
<string name="setup_store_on">메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다.</string>
<string name="setup_store_off">Flathub에서 Linux 앱 및 게임 설치</string>
<string name="setup_tool_files">파일</string>
<string name="setup_tool_files_hint">파일 탐색 및 관리</string>
+1 -1
View File
@@ -536,7 +536,7 @@
<string name="setup_choose">Выбрать</string>
<string name="setup_linux_apps">Приложения Linux (бета)</string>
<string name="setup_store">Магазин Flathub</string>
<string name="setup_store_on">Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck.</string>
<string name="setup_store_on">Показывается в меню. Некоторые приложения могут не запускаться.</string>
<string name="setup_store_off">Устанавливать приложения и игры Linux из Flathub</string>
<string name="setup_tool_files">Файлы</string>
<string name="setup_tool_files_hint">Просмотр и управление файлами</string>
+1 -1
View File
@@ -638,7 +638,7 @@
<string name="setup_choose">选择</string>
<string name="setup_linux_apps">Linux 应用(测试版)</string>
<string name="setup_store">Flathub 商店</string>
<string name="setup_store_on">显示在菜单中。部分应用可能无法启动;日志位于 Download/DroidDeck。</string>
<string name="setup_store_on">显示在菜单中。部分应用可能无法启动。</string>
<string name="setup_store_off">从 Flathub 安装 Linux 应用和游戏</string>
<string name="setup_tool_files">文件</string>
<string name="setup_tool_files_hint">浏览和管理文件</string>
+1 -1
View File
@@ -638,7 +638,7 @@
<string name="setup_choose">選擇</string>
<string name="setup_linux_apps">Linux 應用程式(測試版)</string>
<string name="setup_store">Flathub 商店</string>
<string name="setup_store_on">顯示在選單中。部分應用程式可能無法啟動;記錄位於 Download/DroidDeck。</string>
<string name="setup_store_on">顯示在選單中。部分應用程式可能無法啟動。</string>
<string name="setup_store_off">從 Flathub 安裝 Linux 應用程式和遊戲</string>
<string name="setup_tool_files">檔案</string>
<string name="setup_tool_files_hint">瀏覽及管理檔案</string>
+1 -1
View File
@@ -638,7 +638,7 @@
<string name="setup_choose">選擇</string>
<string name="setup_linux_apps">Linux 應用程式(測試版)</string>
<string name="setup_store">Flathub 商店</string>
<string name="setup_store_on">顯示在選單中。部分應用程式可能無法啟動;日誌位於 Download/DroidDeck。</string>
<string name="setup_store_on">顯示在選單中。部分應用程式可能無法啟動。</string>
<string name="setup_store_off">從 Flathub 安裝 Linux 應用程式和遊戲</string>
<string name="setup_tool_files">檔案</string>
<string name="setup_tool_files_hint">瀏覽和管理檔案</string>
+1 -1
View File
@@ -643,7 +643,7 @@
<string name="setup_choose">Choose</string>
<string name="setup_linux_apps">Linux apps (beta)</string>
<string name="setup_store">Flathub Store</string>
<string name="setup_store_on">In the menu. Some apps may not start; logs are in Download/DroidDeck.</string>
<string name="setup_store_on">In the menu. Some apps may not start.</string>
<string name="setup_store_off">Install Linux apps and games from Flathub</string>
<string name="setup_tool_files">Files</string>
<string name="setup_tool_files_hint">Browse and manage files</string>
@@ -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("<redacted:token>"))
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("<redacted:authorization>"))
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("<redacted:token>"))
assertEquals(out, LogRedactor.redact(out))
}
}
}
@@ -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())
}
}
@@ -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<String, String> =
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<String, String>) {
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("<incomplete live line withheld>"))
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-") })
}
}
+3 -1
View File
@@ -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.
+14 -11
View File
@@ -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)
+51
View File
@@ -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())