fix: share session logs in about a second instead of re-scrubbing them (#352)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-06 14:51:43 -04:00
1 parent 08559a02f8
commit 906009a33a
12 files changed
+362 -60

No files matched your search

@@ -638,13 +638,10 @@ class MainActivity : ComponentActivity() {
logsEnabled = SessionPrefs.logsEnabled(this)
},
onShareLogs = {
Thread({
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
ui.post {
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
}, "share-logs").start()
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
},
onClearLogs = {
Thread({
@@ -741,22 +741,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
return
}
Thread({
val zip = runCatching { SessionLogShare.zipFolder(this, folder) }
.onFailure { Log.w(TAG, "could not package current session logs", it) }
.getOrNull()
uiHandler.post {
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
} else {
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
.onFailure {
Log.w(TAG, "could not share current session logs", it)
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
}
}
SessionLogShare.prepare(this, { folder }) { zip ->
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
} else {
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
.onFailure {
Log.w(TAG, "could not share current session logs", it)
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
}
}
}, "share-session-logs").start()
}
}
private fun readPrefs() {
@@ -190,6 +190,14 @@ object LogRedactor {
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") }
@@ -34,6 +34,16 @@ object SessionArtifacts {
/** 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) {
try {
@@ -44,11 +54,11 @@ object SessionArtifacts {
}
}
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
copySteamLogs(context, dir)
val record = Record(dir)
copySteamLogs(context, dir, record)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
scrubFolder(dir)
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
markScrubbed(scrubTree(record))
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
@@ -71,8 +81,7 @@ object SessionArtifacts {
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
dirs.forEach { dir ->
scrubFolder(dir)
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
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)")
@@ -84,29 +93,121 @@ object SessionArtifacts {
* 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.
*/
private fun scrubFolder(dir: File) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
/** 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
try {
val tmp = File(dir, ".${f.name}.scrub")
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
}
val done = scrubFile(dir, f)
if (done == null) record.failed = true else record.files[f] = done
}
}
/** Steam's logs: redacted into steam/, never copied verbatim. */
private fun copySteamLogs(context: Context, dir: File) {
/**
* 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 ->
try {
File(out, src.name).bufferedWriter().use { w ->
val dst = File(out, src.name)
dst.bufferedWriter().use { w ->
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
}
record.files[dst] = Scrubbed(dst.length(), dst.lastModified())
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${src.name}", e)
}
@@ -143,12 +244,11 @@ 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"
)
if (newest) {
copySteamLogs(context, dir)
}
val record = Record(dir)
if (newest) copySteamLogs(context, dir, record)
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
scrubFolder(dir)
markScrubbed(scrubTree(record))
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) {
@@ -3,6 +3,12 @@ package com.droiddeck.launcher.session
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.FileProvider
import com.droiddeck.launcher.core.LogRedactor
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -16,32 +22,74 @@ import java.util.zip.ZipOutputStream
* it) and landed in the app's private files instead, which a user cannot otherwise reach.
*/
object SessionLogShare {
private val main = Handler(Looper.getMainLooper())
/**
* How far the zip for a share has got, 0..1, or null when none is being made. The Share logs
* buttons show it in place of their label, so a tap is answered at once.
*/
var progress by mutableStateOf<Float?>(null)
private set
/**
* Zips [folder] on a worker thread with [progress] following along, then hands the zip (null
* if there was nothing to zip or it failed) to [done] on the main thread. Call on the main
* thread; a tap while a zip is being made is ignored.
*/
fun prepare(context: Context, folder: () -> File?, done: (File?) -> Unit) {
if (progress != null) return
progress = 0f
Thread({
val zip = runCatching { folder()?.let { dir -> zipFolder(context, dir) { p -> main.post { if (progress != null) progress = p } } } }
.onFailure { Log.w("SessionLogShare", "could not package session logs", it) }
.getOrNull()
main.post { progress = null; done(zip) }
}, "share-logs").start()
}
/** 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. */
fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) }
/** Builds a zip for one specific session folder (blocking). */
fun zipFolder(context: Context, folder: File): File? {
fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? {
if (!folder.isDirectory) return null
val files = folder.walkTopDown().filter { it.isFile }.toList()
// 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.
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) }
}
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
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) {
private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) {
z.putNextEntry(ZipEntry(name))
if (LogRedactor.isText(f)) {
if (!scrubbed && LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
@@ -300,7 +300,8 @@ internal fun SetupPanel(
}
}
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs)
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
ToggleRow(
host, "offline", stringResource(R.string.setup_offline),
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -206,8 +207,27 @@ internal fun PrimaryButton(
}
}
/**
* [label] while idle; while [progress] is set, a spinner and how far along it is in its place.
* The label stays laid out underneath, invisible, so the button keeps its width. The spinner
* turns until there is a first figure to show.
*/
@Composable
internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
internal fun ProgressLabel(label: @Composable (Modifier) -> Unit, progress: Float?, fontSize: androidx.compose.ui.unit.TextUnit = 15.sp) {
val pal = LocalPalette.current
Box(contentAlignment = Alignment.Center) {
label(Modifier.alpha(if (progress == null) 1f else 0f))
if (progress != null) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (progress > 0f) CircularProgressIndicator(progress = { progress }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.22f), modifier = Modifier.size(16.dp))
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
Text("${(progress * 100).toInt()}%", fontSize = fontSize, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = MaterialTheme.colorScheme.onBackground)
}
}
}
@Composable
internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, progress: Float? = null, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && enabled
@@ -222,7 +242,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = if (compact) 10.dp else 16.dp, vertical = if (compact) 7.dp else 11.dp),
) { Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1) }
) {
ProgressLabel({ m -> Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1, modifier = m) },
progress, fontSize = if (compact) 12.sp else 15.sp)
}
}
@Composable
@@ -229,7 +229,8 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC
var firstUsed = false
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(), onClick = onShareLogs)
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(),
progress = com.droiddeck.launcher.session.SessionLogShare.progress, onClick = onShareLogs)
if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose)
}
}
@@ -508,7 +508,8 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
SettingsGroup(stringResource(R.string.drawer_support)) {
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs")) {
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs"),
progress = com.droiddeck.launcher.session.SessionLogShare.progress) {
host.open = null
a.onShareLogs()
}
@@ -584,7 +585,7 @@ private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics.
}
@Composable
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, progress: Float? = null, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val source = remember { MutableInteractionSource() }
@@ -597,7 +598,7 @@ private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onC
contentPadding = PaddingValues(horizontal = 12.dp),
border = BorderStroke(if (hot) 2.dp else 1.dp, if (hot) pal.signal else colors.outline),
colors = ButtonDefaults.outlinedButtonColors(containerColor = if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent),
) { Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) }
) { ProgressLabel({ m -> Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, modifier = m) }, progress, fontSize = 13.sp) }
}
@Composable
@@ -591,8 +591,8 @@ fun MultiRow(
}
@Composable
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit) {
SettingsRow(label, hint) { SecondaryButton(button, onClick = onClick) }
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null) {
SettingsRow(label, hint) { SecondaryButton(button, progress = progress, onClick = onClick) }
}
@Composable
@@ -0,0 +1,125 @@
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 org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.util.zip.ZipFile
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SessionLogShareTest {
@get:Rule val tmp = TemporaryFolder()
private val secret = "GET /x?sessionid=abcdef123456 200"
private val leaked = "abcdef123456"
private fun zip(folder: File): Map<String, String> =
ZipFile(SessionLogShare.zipFolder(RuntimeEnvironment.getApplication(), 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" })
}
@Test fun aRecordedUnchangedFileGoesInAsItIs() {
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))
}
@Test fun anythingTheRecordDoesNotVouchForIsScrubbed() {
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))
}
@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 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() {
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) })
}
@Test fun aFileThatCouldNotBeScrubbedLeavesTheFolderUnrecorded() {
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.
folder.setWritable(false)
try {
SessionArtifacts.scrubAndMark(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) })
}
@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) }
}
val names = zip(folder).keys
assertTrue("steam/console_log.txt" in names)
assertFalse("steam/cef_log.previous.txt" in names)
}
}
@@ -38,7 +38,10 @@ collect_steam_logs() {
# Into steam/ beside this session's own logs. The app scrubs these line by line after the
# session ends - they carry the account's session token and id - so nothing here is the last
# word on what the user ends up sharing; this is just the copy that survives the runtime.
[ -d "$steam_root/logs" ] && cp -r "$steam_root/logs/." "$BL_DEBUG_DIR/steam/" 2>/dev/null
# Up to 8 MB each, as the app's own copy takes them: the client's CEF and *.previous.txt logs
# reach 17 MB and say little about this session, and every share scrubs whatever is here.
[ -d "$steam_root/logs" ] && find "$steam_root/logs" -maxdepth 1 -type f -size -8192k \
-exec cp {} "$BL_DEBUG_DIR/steam/" \; 2>/dev/null
[ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/steam/" 2>/dev/null
# NOT copied, ever: loginusers.vdf, config.vdf and the ssfn* files, which hold the credentials
# themselves rather than a mention of them.