mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-15 03:37:09 -04:00
fix: share session logs in about a second instead of re-scrubbing them (#352)
This commit is contained in:
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.
|
||||
|
||||
Reference in new issue
Block a user