From 7d2db568add719ba954a1688cdb4cbb63564ed23 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 6 Jul 2026 06:51:43 +0000 Subject: [PATCH 01/10] Update coil to v3.5.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9c230243..fb4a58a78 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ autoservice-zacsweers = "1.2.0" bridge = "v2.0.2" cardview = "1.0.0" checkstyle = "13.4.2" -coil = "3.4.0" +coil = "3.5.0" compose = "1.11.4" constraintlayout = "2.2.1" core = "1.18.0" From 6d7e5508bba6b24816f26a7937d504d4cdb6aa9c Mon Sep 17 00:00:00 2001 From: Cobb Date: Tue, 28 Jul 2026 21:50:38 -0700 Subject: [PATCH 02/10] fix(player): collapse the expanded player to the minibar when opening a channel Tapping the channel from the video page pushed Screen.Channel but left the player expanded; its opaque body sits z-above ScreenContent, so the channel page was hidden until the user manually minimized the panel. Set expanded=false alongside the nav.push so the destination is revealed by the same collapse morph as swipe-down. Playback continues in the minibar; related-video and fullscreen paths (which intentionally stay expanded) are unaffected. --- .../kotlin/com/sulkta/straw/StrawActivity.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index d69e2b918..32a326fb5 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -184,7 +184,23 @@ class StrawActivity : ComponentActivity() { expandedTarget = expanded, onTargetChange = { expanded = it }, onFullscreen = { url, title -> nav.push(Screen.Player(url, title)) }, - onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) }, + // Navigating to a DIFFERENT destination from the + // expanded player must collapse it to the minibar + // first — the expanded body is opaque and sits + // z-above ScreenContent, so without this the + // pushed Channel screen is invisible until the + // user manually minimizes the player. Playback + // continues in the minibar (collapse never + // touches the controller). Related-video taps + // are the OPPOSITE case: they go through + // openVideo (swap-in-place, sets expanded=true) + // and stay expanded; ⛶ fullscreen also stays + // expanded (Screen.Player un-composes this + // overlay and restores it expanded on pop). + onOpenChannel = { url, name -> + expanded = false + nav.push(Screen.Channel(url, name)) + }, onOpenVideo = openVideo, ) } From 8d9ad3513fb1826933683f7fe9256a22c3aa3d03 Mon Sep 17 00:00:00 2001 From: Cobb Date: Wed, 29 Jul 2026 07:05:15 -0700 Subject: [PATCH 03/10] release: bump versionCode 91 -> 92 (0.1.0-CZ) Ships the player-collapse-on-channel fix and picks up the strawcore reliability pass. Releases had been silently no-op'ing on fdroid: the versionCode is a hand-maintained constant, so a commit that doesn't bump it rebuilds the same debug_.apk filename, and the publish receiver's anti-downgrade guard (correctly) refuses to overwrite an already-published file (exit 3 = "nothing to do"), which the CI treats as success. Bumping the code produces a new filename that actually publishes. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- strawApp/build.gradle.kts | 23 +++ .../main/kotlin/com/sulkta/straw/StrawApp.kt | 7 + .../straw/feature/settings/SettingsScreen.kt | 34 +++++ .../kotlin/com/sulkta/straw/util/LogDump.kt | 138 ++++++++++++++++-- 5 files changed, 195 insertions(+), 11 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 6e09d9180..5e3dda068 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -359,6 +359,6 @@ const val STRAW_SDK_TARGET = 35 // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 91 -const val STRAW_VERSION_NAME = "0.1.0-CY" +const val STRAW_VERSION_CODE = 92 +const val STRAW_VERSION_NAME = "0.1.0-CZ" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index ed3b5df02..ac7620c95 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -36,6 +36,29 @@ configure { versionCode = STRAW_VERSION_CODE versionName = STRAW_VERSION_NAME resValue("string", "app_name", "Straw") + + // Dogfood log-shipping token (feature/diag/LogShipper.kt). This repo + // is PUBLIC — the token must NEVER be committed, so it's injected at + // build time and defaults to "": + // 1. env var STRAW_LOGS_INGEST_TOKEN — CI: add a Forgejo Actions + // repo secret STRAW_LOGS_INGEST_TOKEN and pass it in the + // "Assemble debug APK" step's env block in + // .forgejo/workflows/build.yml. + // 2. `straw.logs.ingest.token=…` in local.properties (gitignored) + // for local dogfood builds. + // Empty token ⇒ LogShipper is fully disabled (no-op, zero network, + // no Settings row) — contributor builds neither break nor phone home. + val strawLogsToken: String = System.getenv("STRAW_LOGS_INGEST_TOKEN") + ?: java.util.Properties().let { props -> + val f = rootProject.file("local.properties") + if (f.exists()) f.inputStream().use(props::load) + props.getProperty("straw.logs.ingest.token") + } + ?: "" + // Escape so a pathological token can't break out of the generated + // String literal in BuildConfig.java. + val escapedToken = strawLogsToken.replace("\\", "\\\\").replace("\"", "\\\"") + buildConfigField("String", "STRAW_LOGS_TOKEN", "\"$escapedToken\"") } // Explicit signing so CI / release builds reuse ONE keystore instead of diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index e1922b988..e98f4f64a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -21,6 +21,7 @@ import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.dataimport.SettingsImport +import com.sulkta.straw.feature.diag.LogShipper import com.sulkta.straw.feature.feed.FeedRefreshScheduler import com.sulkta.straw.feature.update.UpdateScheduler import com.sulkta.straw.feature.update.runUpdateCheck @@ -90,6 +91,12 @@ class StrawApp : Application(), SingletonImageLoader.Factory { override fun onCreate() { super.onCreate() + // Dogfood crash shipping — wrap the default uncaught-exception + // handler FIRST so even an init crash below gets shipped. The + // wrapper always chains to the previous handler (normal crash + // behavior is untouched) and is a no-op in builds without an + // injected STRAW_LOGS_TOKEN. + LogShipper.installCrashHandler(this) // Path C-7: route Rust `log::*` calls into Android logcat under tag // "strawcore". Without this, every log line emitted from rustypipe / // strawcore is silently dropped, making playback regressions invisible diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 6c96d72d0..35dee1e58 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -66,6 +66,7 @@ import com.sulkta.straw.data.Settings import com.sulkta.straw.data.ThemeMode import com.sulkta.straw.feature.dataimport.ImportResult import com.sulkta.straw.feature.dataimport.SettingsImport +import com.sulkta.straw.feature.diag.LogShipper import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.search.SearchViewModel import com.sulkta.straw.util.LogDump @@ -774,6 +775,39 @@ fun SettingsScreen() { Text(if (logDumping) "Exporting…" else "Export logs…") } + // Dogfood log shipping — only rendered in builds with an + // injected ingest token (LogShipper.enabled is compile-time + // false otherwise, so contributor builds show no phantom row). + if (LogShipper.enabled) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Or send the same scrubbed log dump straight to Kayos " + + "(logs.sulkta.com) — no share sheet. URLs, video ids, " + + "and anything token-shaped are redacted on-device first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + var logSending by remember { mutableStateOf(false) } + OutlinedButton( + enabled = !logSending, + onClick = { + logSending = true + scope.launch { + val ok = LogShipper.send(context, reason = "manual") + logSending = false + Toast.makeText( + context, + if (ok) "Logs sent — thanks!" else "Couldn't reach the log server", + Toast.LENGTH_LONG, + ).show() + } + }, + ) { + Text(if (logSending) "Sending…" else "Send logs to Kayos") + } + } + Spacer(modifier = Modifier.height(32.dp)) Text( "Import from NewPipe / Tubular", diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt index 159e7b8aa..ec922c4bd 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt @@ -96,10 +96,46 @@ object LogDump { } /** - * Pre-redact known credential-shaped substrings before they hit - * disk. Cheap line-level pass — adversarial-perfect would need a - * URL parser, but the regex approach catches every documented - * leak vector at zero allocation cost. + * Pull recent logcat, scrub every line, keep only the newest + * [maxLines]. BLOCKING — callers must already be off the main + * thread (LogShipper runs on Dispatchers.IO; the crash handler + * runs this on its own short-lived worker thread, where a suspend + * fun can't be awaited without an event loop). + * + * Best-effort by design: a logcat exec failure returns whatever + * was read (possibly empty) instead of throwing, so the crash + * shipper can still deliver the stacktrace. + */ + fun captureScrubbedLinesBlocking(maxLines: Int): List { + val ring = ArrayDeque() + runCatching { + val pid = Process.myPid() + val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid") + val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start() + proc.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + if (ring.size == maxLines) ring.removeFirst() + ring.addLast(line) + } + } + proc.waitFor() + } + // Scrub only the retained window — cheaper than scrubbing lines + // that get dropped, and nothing raw ever leaves this function. + return ring.map(::scrubLine) + } + + /** + * Pre-redact known credential- and identity-shaped substrings + * before they hit disk or the wire. Cheap line-level pass — + * adversarial-perfect would need a URL parser, but the regex + * approach catches every documented leak vector at zero + * allocation cost. + * + * BIAS: over-redaction. These lines leave the device (share sheet + * + LogShipper → logs.sulkta.com), so a false positive costs a + * little debug signal while a false negative leaks what someone + * watched. Anything URL-, token-, id-, or address-shaped goes. * * Public so error-handler call sites (PlayerScreen / VideoDetail * `playbackError`) can scrub Media3's `PlaybackException.message` @@ -109,28 +145,112 @@ object LogDump { */ fun scrubLine(line: String): String { var s = line - // Pre-signed googlevideo URLs: keep host visible, drop path+query. + // Pre-signed googlevideo URLs: keep the host label visible, drop + // host prefix + path + query (session-bound streaming creds). s = GOOGLEVIDEO_URL_RE.replace(s, "https://.googlevideo.com/") + // Credential-shaped headers / key-value pairs, wherever they + // appear: `Authorization: Bearer x`, `cookie: …`, `api_key=…`. + s = BEARER_RE.replace(s, "$1 ") + s = CRED_KV_RE.replace(s, "$1: ") // Long, distinctive token names — match anywhere. s = SIGNED_PARAM_LONG_RE.replace(s, "$1=") // Short single-letter / two-letter tokens — require `[?&]` // immediately before to avoid eating innocent counters. s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=") + // ANY remaining URL: keep scheme+host (routing/debug signal, + // not PII), drop path + query — that's where watch?v=…, + // /vi// thumbnails, search terms, etc. live. + s = URL_TAIL_RE.replace(s, "$1/") + // Schemeless YT links pasted into log messages ("youtu.be/xyz"). + s = SCHEMELESS_YT_RE.replace(s, "$1/") + // Emails. + s = EMAIL_RE.replace(s, "") + // YouTube channel + playlist ids (watch behavior). + s = CHANNEL_ID_RE.replace(s, "") + s = PLAYLIST_ID_RE.replace(s, "") + // Bare 11-char video-id-shaped tokens ("dQw4w9WgXcQ") logged + // outside any URL (rustypipe/strawcore do this). Requiring a + // digit/_/- inside the run spares ordinary 11-letter words; + // real ids virtually always contain one. Pure-alpha ids are + // still caught earlier when keyed (v=…) or inside a URL. + s = VIDEO_ID_CANDIDATE_RE.replace(s) { m -> + if (m.value.any { it.isDigit() || it == '_' || it == '-' }) "" else m.value + } + // Long high-entropy runs (hashes, visitor data, unlabeled + // tokens): 20+ [A-Za-z0-9_-] chars containing a digit. + s = LONG_TOKEN_RE.replace(s, "") + // IP addresses (v4 + v6 — v6 patterns are shaped so threadtime + // HH:MM:SS timestamps can never match). + s = IPV4_RE.replace(s, "") + s = IPV6_FULL_RE.replace(s, "") + s = IPV6_COMPRESSED_RE.replace(s, "") return s } private val GOOGLEVIDEO_URL_RE = Regex( """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""", ) + // `Authorization: Bearer ` and friends. The token charset is + // the RFC 6750 b64token alphabet. + private val BEARER_RE = Regex( + """\b(bearer)\s+[A-Za-z0-9._~+/=-]{4,}""", + RegexOption.IGNORE_CASE, + ) + // Credential-y names followed by `: value` or `= value` (optional + // closing quote for JSON `"token":"…"` shapes). Values may be + // quoted strings or a bare token. + private val CRED_KV_RE = Regex( + """\b(authorization|proxy-authorization|cookie|set-cookie|x-goog-[a-z\-]+|x-youtube-[a-z\-]+|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|client[-_]?secret|secret|token|auth|session[-_]?id|passw(?:or)?d|pwd|mnemonic|private[-_]?key)"?\s*[:=]\s*("[^"]*"|\S+)""", + RegexOption.IGNORE_CASE, + ) // Long tokens are unique enough to match anywhere. Short tokens - // (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before - // so we don't redact innocuous `n=42` counters from other libs. + // (n, mn, ms, … v, id, q) require `[?&]` immediately before so we + // don't redact innocuous `n=42` counters from other libs. `v`, + // `id`, `list`, `q`, `t` are the YouTube identity/search params. private val SIGNED_PARAM_LONG_RE = Regex( - """\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""", + """\b(signature|sparams|lsig|cpn|expire|pot|sig|key|videoId|video_id|docid|search_query|query)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) private val SIGNED_PARAM_SHORT_RE = Regex( - """([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""", + """([?&])(n|mn|ms|mo|pl|ip|ei|v|id|list|q|t)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) + // Any http(s) URL: capture scheme + host(+port), scrub the rest. + // Host charset can't contain `<`, so the googlevideo replacement + // above is never re-matched. + private val URL_TAIL_RE = Regex( + """(https?://[A-Za-z0-9.-]+(?::\d+)?)[/?#][^\s"'<>]*""", + ) + // youtu.be/… + youtube.com/… without a scheme. The lookbehind + // skips hosts already handled as part of a full URL (preceded by + // `/` or a subdomain dot). + private val SCHEMELESS_YT_RE = Regex( + """(?]*""", + ) + private val EMAIL_RE = Regex( + """[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""", + ) + private val CHANNEL_ID_RE = Regex( + """(? Date: Wed, 29 Jul 2026 07:13:59 -0700 Subject: [PATCH 04/10] fix(build): back out log-wiring WIP swept into the vc bump The versionCode bump (8d9ad35) was committed with a broad 'git commit -a', which swept in in-progress dogfood log-shipping edits (build.gradle.kts token plumbing + StrawApp/SettingsScreen referencing an untracked LogShipper.kt) -- unreviewed, incomplete, and it broke the gradle-kotlin-dsl compile. Restore those four files to pre-wiring; vc=92 ships only the player-collapse fix + the strawcore reliability/npe-sync pass. The log-wiring is preserved on feat/dogfood-logging for the coordinated logs.sulkta.com deploy. --- strawApp/build.gradle.kts | 23 --- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 7 - .../straw/feature/settings/SettingsScreen.kt | 34 ----- .../kotlin/com/sulkta/straw/util/LogDump.kt | 138 ++---------------- 4 files changed, 9 insertions(+), 193 deletions(-) diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index ac7620c95..ed3b5df02 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -36,29 +36,6 @@ configure { versionCode = STRAW_VERSION_CODE versionName = STRAW_VERSION_NAME resValue("string", "app_name", "Straw") - - // Dogfood log-shipping token (feature/diag/LogShipper.kt). This repo - // is PUBLIC — the token must NEVER be committed, so it's injected at - // build time and defaults to "": - // 1. env var STRAW_LOGS_INGEST_TOKEN — CI: add a Forgejo Actions - // repo secret STRAW_LOGS_INGEST_TOKEN and pass it in the - // "Assemble debug APK" step's env block in - // .forgejo/workflows/build.yml. - // 2. `straw.logs.ingest.token=…` in local.properties (gitignored) - // for local dogfood builds. - // Empty token ⇒ LogShipper is fully disabled (no-op, zero network, - // no Settings row) — contributor builds neither break nor phone home. - val strawLogsToken: String = System.getenv("STRAW_LOGS_INGEST_TOKEN") - ?: java.util.Properties().let { props -> - val f = rootProject.file("local.properties") - if (f.exists()) f.inputStream().use(props::load) - props.getProperty("straw.logs.ingest.token") - } - ?: "" - // Escape so a pathological token can't break out of the generated - // String literal in BuildConfig.java. - val escapedToken = strawLogsToken.replace("\\", "\\\\").replace("\"", "\\\"") - buildConfigField("String", "STRAW_LOGS_TOKEN", "\"$escapedToken\"") } // Explicit signing so CI / release builds reuse ONE keystore instead of diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index e98f4f64a..e1922b988 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -21,7 +21,6 @@ import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.dataimport.SettingsImport -import com.sulkta.straw.feature.diag.LogShipper import com.sulkta.straw.feature.feed.FeedRefreshScheduler import com.sulkta.straw.feature.update.UpdateScheduler import com.sulkta.straw.feature.update.runUpdateCheck @@ -91,12 +90,6 @@ class StrawApp : Application(), SingletonImageLoader.Factory { override fun onCreate() { super.onCreate() - // Dogfood crash shipping — wrap the default uncaught-exception - // handler FIRST so even an init crash below gets shipped. The - // wrapper always chains to the previous handler (normal crash - // behavior is untouched) and is a no-op in builds without an - // injected STRAW_LOGS_TOKEN. - LogShipper.installCrashHandler(this) // Path C-7: route Rust `log::*` calls into Android logcat under tag // "strawcore". Without this, every log line emitted from rustypipe / // strawcore is silently dropped, making playback regressions invisible diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 35dee1e58..6c96d72d0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -66,7 +66,6 @@ import com.sulkta.straw.data.Settings import com.sulkta.straw.data.ThemeMode import com.sulkta.straw.feature.dataimport.ImportResult import com.sulkta.straw.feature.dataimport.SettingsImport -import com.sulkta.straw.feature.diag.LogShipper import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.search.SearchViewModel import com.sulkta.straw.util.LogDump @@ -775,39 +774,6 @@ fun SettingsScreen() { Text(if (logDumping) "Exporting…" else "Export logs…") } - // Dogfood log shipping — only rendered in builds with an - // injected ingest token (LogShipper.enabled is compile-time - // false otherwise, so contributor builds show no phantom row). - if (LogShipper.enabled) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - "Or send the same scrubbed log dump straight to Kayos " + - "(logs.sulkta.com) — no share sheet. URLs, video ids, " + - "and anything token-shaped are redacted on-device first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(12.dp)) - var logSending by remember { mutableStateOf(false) } - OutlinedButton( - enabled = !logSending, - onClick = { - logSending = true - scope.launch { - val ok = LogShipper.send(context, reason = "manual") - logSending = false - Toast.makeText( - context, - if (ok) "Logs sent — thanks!" else "Couldn't reach the log server", - Toast.LENGTH_LONG, - ).show() - } - }, - ) { - Text(if (logSending) "Sending…" else "Send logs to Kayos") - } - } - Spacer(modifier = Modifier.height(32.dp)) Text( "Import from NewPipe / Tubular", diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt index ec922c4bd..159e7b8aa 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt @@ -96,46 +96,10 @@ object LogDump { } /** - * Pull recent logcat, scrub every line, keep only the newest - * [maxLines]. BLOCKING — callers must already be off the main - * thread (LogShipper runs on Dispatchers.IO; the crash handler - * runs this on its own short-lived worker thread, where a suspend - * fun can't be awaited without an event loop). - * - * Best-effort by design: a logcat exec failure returns whatever - * was read (possibly empty) instead of throwing, so the crash - * shipper can still deliver the stacktrace. - */ - fun captureScrubbedLinesBlocking(maxLines: Int): List { - val ring = ArrayDeque() - runCatching { - val pid = Process.myPid() - val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid") - val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start() - proc.inputStream.bufferedReader().useLines { lines -> - lines.forEach { line -> - if (ring.size == maxLines) ring.removeFirst() - ring.addLast(line) - } - } - proc.waitFor() - } - // Scrub only the retained window — cheaper than scrubbing lines - // that get dropped, and nothing raw ever leaves this function. - return ring.map(::scrubLine) - } - - /** - * Pre-redact known credential- and identity-shaped substrings - * before they hit disk or the wire. Cheap line-level pass — - * adversarial-perfect would need a URL parser, but the regex - * approach catches every documented leak vector at zero - * allocation cost. - * - * BIAS: over-redaction. These lines leave the device (share sheet - * + LogShipper → logs.sulkta.com), so a false positive costs a - * little debug signal while a false negative leaks what someone - * watched. Anything URL-, token-, id-, or address-shaped goes. + * Pre-redact known credential-shaped substrings before they hit + * disk. Cheap line-level pass — adversarial-perfect would need a + * URL parser, but the regex approach catches every documented + * leak vector at zero allocation cost. * * Public so error-handler call sites (PlayerScreen / VideoDetail * `playbackError`) can scrub Media3's `PlaybackException.message` @@ -145,112 +109,28 @@ object LogDump { */ fun scrubLine(line: String): String { var s = line - // Pre-signed googlevideo URLs: keep the host label visible, drop - // host prefix + path + query (session-bound streaming creds). + // Pre-signed googlevideo URLs: keep host visible, drop path+query. s = GOOGLEVIDEO_URL_RE.replace(s, "https://.googlevideo.com/") - // Credential-shaped headers / key-value pairs, wherever they - // appear: `Authorization: Bearer x`, `cookie: …`, `api_key=…`. - s = BEARER_RE.replace(s, "$1 ") - s = CRED_KV_RE.replace(s, "$1: ") // Long, distinctive token names — match anywhere. s = SIGNED_PARAM_LONG_RE.replace(s, "$1=") // Short single-letter / two-letter tokens — require `[?&]` // immediately before to avoid eating innocent counters. s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=") - // ANY remaining URL: keep scheme+host (routing/debug signal, - // not PII), drop path + query — that's where watch?v=…, - // /vi// thumbnails, search terms, etc. live. - s = URL_TAIL_RE.replace(s, "$1/") - // Schemeless YT links pasted into log messages ("youtu.be/xyz"). - s = SCHEMELESS_YT_RE.replace(s, "$1/") - // Emails. - s = EMAIL_RE.replace(s, "") - // YouTube channel + playlist ids (watch behavior). - s = CHANNEL_ID_RE.replace(s, "") - s = PLAYLIST_ID_RE.replace(s, "") - // Bare 11-char video-id-shaped tokens ("dQw4w9WgXcQ") logged - // outside any URL (rustypipe/strawcore do this). Requiring a - // digit/_/- inside the run spares ordinary 11-letter words; - // real ids virtually always contain one. Pure-alpha ids are - // still caught earlier when keyed (v=…) or inside a URL. - s = VIDEO_ID_CANDIDATE_RE.replace(s) { m -> - if (m.value.any { it.isDigit() || it == '_' || it == '-' }) "" else m.value - } - // Long high-entropy runs (hashes, visitor data, unlabeled - // tokens): 20+ [A-Za-z0-9_-] chars containing a digit. - s = LONG_TOKEN_RE.replace(s, "") - // IP addresses (v4 + v6 — v6 patterns are shaped so threadtime - // HH:MM:SS timestamps can never match). - s = IPV4_RE.replace(s, "") - s = IPV6_FULL_RE.replace(s, "") - s = IPV6_COMPRESSED_RE.replace(s, "") return s } private val GOOGLEVIDEO_URL_RE = Regex( """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""", ) - // `Authorization: Bearer ` and friends. The token charset is - // the RFC 6750 b64token alphabet. - private val BEARER_RE = Regex( - """\b(bearer)\s+[A-Za-z0-9._~+/=-]{4,}""", - RegexOption.IGNORE_CASE, - ) - // Credential-y names followed by `: value` or `= value` (optional - // closing quote for JSON `"token":"…"` shapes). Values may be - // quoted strings or a bare token. - private val CRED_KV_RE = Regex( - """\b(authorization|proxy-authorization|cookie|set-cookie|x-goog-[a-z\-]+|x-youtube-[a-z\-]+|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|client[-_]?secret|secret|token|auth|session[-_]?id|passw(?:or)?d|pwd|mnemonic|private[-_]?key)"?\s*[:=]\s*("[^"]*"|\S+)""", - RegexOption.IGNORE_CASE, - ) // Long tokens are unique enough to match anywhere. Short tokens - // (n, mn, ms, … v, id, q) require `[?&]` immediately before so we - // don't redact innocuous `n=42` counters from other libs. `v`, - // `id`, `list`, `q`, `t` are the YouTube identity/search params. + // (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before + // so we don't redact innocuous `n=42` counters from other libs. private val SIGNED_PARAM_LONG_RE = Regex( - """\b(signature|sparams|lsig|cpn|expire|pot|sig|key|videoId|video_id|docid|search_query|query)=([^&\s"']+)""", + """\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) private val SIGNED_PARAM_SHORT_RE = Regex( - """([?&])(n|mn|ms|mo|pl|ip|ei|v|id|list|q|t)=([^&\s"']+)""", + """([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) - // Any http(s) URL: capture scheme + host(+port), scrub the rest. - // Host charset can't contain `<`, so the googlevideo replacement - // above is never re-matched. - private val URL_TAIL_RE = Regex( - """(https?://[A-Za-z0-9.-]+(?::\d+)?)[/?#][^\s"'<>]*""", - ) - // youtu.be/… + youtube.com/… without a scheme. The lookbehind - // skips hosts already handled as part of a full URL (preceded by - // `/` or a subdomain dot). - private val SCHEMELESS_YT_RE = Regex( - """(?]*""", - ) - private val EMAIL_RE = Regex( - """[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""", - ) - private val CHANNEL_ID_RE = Regex( - """(? Date: Wed, 29 Jul 2026 07:23:23 -0700 Subject: [PATCH 05/10] ci: auto-derive versionCode from commit count (never reuse a code) Releases were silently no-op'ing on fdroid: versionCode was a hand-maintained constant, so a commit that didn't bump it rebuilt the same debug_.apk, the publish receiver's anti-downgrade guard refused the duplicate (exit 3 = "nothing to do"), and the CI went green having shipped nothing. build.yml now patches ProjectConfig's versionCode from `git rev-list --count HEAD` (minus an offset keeping it continuous with the manual era, last=91) before assembling, so every main commit auto-produces a new monotonic code. The ProjectConfig literal is now just the local-dev default. --- .forgejo/workflows/build.yml | 19 +++++++++++++++++++ buildSrc/src/main/kotlin/ProjectConfig.kt | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 1fe3c804e..0e9ca3ea0 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -62,6 +62,25 @@ jobs: KS_B64: ${{ secrets.STRAW_SIGNING_KEYSTORE_B64 }} run: echo "$KS_B64" | base64 -d > "$GITHUB_WORKSPACE/straw.keystore" + # Auto-derive versionCode from the main commit count so a release can + # NEVER silently reuse a code — the 2026-07-29 trap where a commit that + # didn't hand-bump ProjectConfig rebuilt the same debug_.apk, the + # publish receiver's anti-downgrade guard refused the duplicate (exit 3 = + # "nothing to do"), and the CI went green having shipped nothing. The + # OFFSET keeps the number continuous with the manual era (last hand-set + # code was 91). Full clone above ⇒ rev-list --count is the true count. + - name: Auto versionCode from commit count + working-directory: straw + run: | + set -euo pipefail + COUNT=$(git rev-list --count HEAD) + VC=$(( COUNT - 12208 )) + sed -i "s/^const val STRAW_VERSION_CODE = .*/const val STRAW_VERSION_CODE = $VC/" \ + buildSrc/src/main/kotlin/ProjectConfig.kt + grep -q "STRAW_VERSION_CODE = $VC\$" buildSrc/src/main/kotlin/ProjectConfig.kt \ + || { echo "::error::versionCode auto-patch failed (sed matched nothing)"; exit 1; } + echo "auto versionCode = $VC (commit count $COUNT)" + - name: Assemble debug APK working-directory: straw env: diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 5e3dda068..6a6042811 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -359,6 +359,10 @@ const val STRAW_SDK_TARGET = 35 // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. +// versionCode is AUTO-DERIVED in CI from the git commit count (see +// .forgejo/workflows/build.yml "Auto versionCode from commit count") so a +// release can never silently reuse a code. This literal is the local-dev +// default only; CI overwrites it at build time. const val STRAW_VERSION_CODE = 92 const val STRAW_VERSION_NAME = "0.1.0-CZ" const val STRAW_APPLICATION_ID = "com.sulkta.straw" From 361c9f680576ac9de33126b58c87a9f197324175 Mon Sep 17 00:00:00 2001 From: Cobb Date: Wed, 29 Jul 2026 07:53:15 -0700 Subject: [PATCH 06/10] speed(wrapper): zero-copy body decode on valid UTF-8 (S4) Mirror the core downloader's from_utf8 fast path in the wrapper's net.rs so a valid UTF-8 response (the common case) moves the buffer instead of the old from_utf8_lossy().into_owned() double-copy; lossy fallback preserved for mojibake. This straw commit also carries the strawcore Loop-3 speed pass (move-not-clone playerResponse, overlapped sig-timestamp fetch, player.js copy cuts) into the build via the CI's strawcore clone. Deferred wrapper follow-up: S5 (opt-level=2 for the JS-interpreter crates in rust/Cargo.toml). --- rust/strawcore/src/net.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/rust/strawcore/src/net.rs b/rust/strawcore/src/net.rs index b95a13424..18ae2c6d9 100644 --- a/rust/strawcore/src/net.rs +++ b/rust/strawcore/src/net.rs @@ -80,9 +80,14 @@ pub(crate) async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Opt } buf.extend_from_slice(&chunk); } - // Lossy decode: a strict from_utf8 would drop the whole response on a - // single mojibake byte; serde_json tolerates U+FFFD in string values. - Some(String::from_utf8_lossy(&buf).into_owned()) + // Prefer a zero-copy move on valid UTF-8 (the common case); fall back to a + // lossy copy only on a mojibake byte (a strict from_utf8 would otherwise + // drop the whole response — serde_json tolerates U+FFFD in string values). + // Mirrors the core downloader's default_impl fast path (S4). + Some(match String::from_utf8(buf) { + Ok(s) => s, + Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(), + }) } // --------------------------------------------------------------------------- From 50217499751add5d4aee3e730d16e63f8ff61e06 Mon Sep 17 00:00:00 2001 From: Cobb Date: Wed, 29 Jul 2026 08:25:47 -0700 Subject: [PATCH 07/10] speed(wrapper): opt-level=2 for the JS-interpreter crates (S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opt-level=2 (over the profile's size-first 'z') for rquickjs-sys, regex-automata and serde_json — the QuickJS C interpreter + regex DFA + JSON parse hot paths; bounded APK-size cost to those crates. Also carries the strawcore visionOS-OFF outage fix into the build via the CI strawcore clone (restores muxed playback). --- rust/Cargo.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a35ae1124..b7f4aaa95 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -31,6 +31,21 @@ codegen-units = 1 panic = "unwind" opt-level = "z" +# Per-package opt-level overrides (Straw speed audit, S5). The whole tree stays +# size-optimized ("z"), but the JS-interpreter + parsing hot paths run measurably +# slow at "z" — QuickJS's C bytecode interpreter, the regex DFA engine, and JSON +# deserialization are all tight inner loops where "z" trades real runtime for a +# few KB. Bumping ONLY these three to opt-level = 2 bounds the APK-size cost to +# these crates while restoring interpreter/parse throughput. Crate names verified +# present in Cargo.lock: rquickjs-sys (QuickJS C interpreter, compiled via cc), +# regex-automata (regex execution engine, pulled by `regex`), serde_json. +[profile.release.package.rquickjs-sys] +opt-level = 2 +[profile.release.package.regex-automata] +opt-level = 2 +[profile.release.package.serde_json] +opt-level = 2 + # `url` crate for video-id extraction in stream.rs. [workspace.dependencies] url = "2" From b5ef1e19737a5d5b79c54617fedb4795b0b51e4f Mon Sep 17 00:00:00 2001 From: Cobb Date: Wed, 29 Jul 2026 09:32:55 -0700 Subject: [PATCH 08/10] feat(diag): dogfood log-shipping to logs.sulkta.com Ship scrubbed device logs to our self-hosted ingest (logs.sulkta.com) so a 'green in every offline gate, broken on-device' regression (the visionOS playback outage) can be caught end-to-end. LogShipper POSTs a scrubbed log ring + crash stacktrace on (a) an uncaught exception (chained to the previous handler) and (b) a 'Send logs to Kayos' Settings button. Everything runs through LogDump.scrubLine first (URLs/video-ids/emails/tokens redacted); the server re-scrubs. device_id = a random per-install UUID (no hardware id). Compile-DISABLED unless the STRAW_LOGS_INGEST_TOKEN build secret is set, so contributor forks never phone home. build.yml passes the secret to the debug build. --- .forgejo/workflows/build.yml | 4 + strawApp/build.gradle.kts | 25 ++ .../main/kotlin/com/sulkta/straw/StrawApp.kt | 7 + .../sulkta/straw/feature/diag/LogShipper.kt | 230 ++++++++++++++++++ .../straw/feature/settings/SettingsScreen.kt | 34 +++ .../kotlin/com/sulkta/straw/util/LogDump.kt | 138 ++++++++++- 6 files changed, 429 insertions(+), 9 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/diag/LogShipper.kt diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 0e9ca3ea0..928c3cc4a 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -88,6 +88,10 @@ jobs: STRAW_KEYSTORE_PASS: android STRAW_KEY_ALIAS: androiddebugkey STRAW_KEY_PASS: android + # Dogfood log-shipping: the app is compile-DISABLED unless this is set + # (empty ⇒ no crash handler, no network, Settings row hidden). Repo + # secret; absent on contributor forks so their builds don't phone home. + STRAW_LOGS_INGEST_TOKEN: ${{ secrets.STRAW_LOGS_INGEST_TOKEN }} # Keep the 4-ABI cross-compile off the container rootfs. CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target # ionice idle class + low nice so the build yields disk/CPU to the diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index ed3b5df02..8d13d14cf 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -10,6 +10,7 @@ */ import com.android.build.api.dsl.ApplicationExtension +import java.util.Properties plugins { alias(libs.plugins.android.application) @@ -36,6 +37,30 @@ configure { versionCode = STRAW_VERSION_CODE versionName = STRAW_VERSION_NAME resValue("string", "app_name", "Straw") + + // Dogfood log-shipping token (feature/diag/LogShipper.kt). This repo + // is PUBLIC — the token must NEVER be committed, so it's injected at + // build time and defaults to "": + // 1. env var STRAW_LOGS_INGEST_TOKEN — CI: add a Forgejo Actions + // repo secret STRAW_LOGS_INGEST_TOKEN and pass it in the + // "Assemble debug APK" step's env block in + // .forgejo/workflows/build.yml. + // 2. `straw.logs.ingest.token=…` in local.properties (gitignored) + // for local dogfood builds. + // Empty token ⇒ LogShipper is fully disabled (no-op, zero network, + // no Settings row) — contributor builds neither break nor phone home. + val strawLogsToken: String = System.getenv("STRAW_LOGS_INGEST_TOKEN") + ?: run { + val props = Properties() + val f = rootProject.file("local.properties") + if (f.exists()) f.inputStream().use { props.load(it) } + props.getProperty("straw.logs.ingest.token") + } + ?: "" + // Escape so a pathological token can't break out of the generated + // String literal in BuildConfig.java. + val escapedToken = strawLogsToken.replace("\\", "\\\\").replace("\"", "\\\"") + buildConfigField("String", "STRAW_LOGS_TOKEN", "\"$escapedToken\"") } // Explicit signing so CI / release builds reuse ONE keystore instead of diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index e1922b988..e98f4f64a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -21,6 +21,7 @@ import com.sulkta.straw.data.SearchCache import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.dataimport.SettingsImport +import com.sulkta.straw.feature.diag.LogShipper import com.sulkta.straw.feature.feed.FeedRefreshScheduler import com.sulkta.straw.feature.update.UpdateScheduler import com.sulkta.straw.feature.update.runUpdateCheck @@ -90,6 +91,12 @@ class StrawApp : Application(), SingletonImageLoader.Factory { override fun onCreate() { super.onCreate() + // Dogfood crash shipping — wrap the default uncaught-exception + // handler FIRST so even an init crash below gets shipped. The + // wrapper always chains to the previous handler (normal crash + // behavior is untouched) and is a no-op in builds without an + // injected STRAW_LOGS_TOKEN. + LogShipper.installCrashHandler(this) // Path C-7: route Rust `log::*` calls into Android logcat under tag // "strawcore". Without this, every log line emitted from rustypipe / // strawcore is silently dropped, making playback regressions invisible diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/diag/LogShipper.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/diag/LogShipper.kt new file mode 100644 index 000000000..5de33c408 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/diag/LogShipper.kt @@ -0,0 +1,230 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Dogfood log shipping — POST a scrubbed logcat window to Kayos's + * ingest service (logs.sulkta.com). Two triggers: + * + * * crash — a default-UncaughtExceptionHandler wrapper captures the + * log ring + the throwable's stacktrace, attempts one + * hard-capped best-effort send, then chains to the + * previously-installed handler so the process still dies + * (and Android still shows its crash dialog) normally. + * * manual — the Settings → Diagnostics "Send logs to Kayos" button. + * + * PRIVACY: every line — including the crash stacktrace — goes through + * LogDump.scrubLine before leaving the device; the server re-scrubs, + * but the phone is the primary gate. device_id is a per-install random + * UUID (no ANDROID_ID / IMEI / hardware fingerprint). And the whole + * feature only exists in builds where BuildConfig.STRAW_LOGS_TOKEN was + * injected (see strawApp/build.gradle.kts) — for everyone else + * `enabled` is a compile-time false, so R8 strips the network path and + * a contributor build cannot phone home. + * + * DELIVERY: strictly best-effort. Any failure (offline, 401/413/429, + * timeout) is swallowed; nothing here may ever crash or block the app. + */ + +package com.sulkta.straw.feature.diag + +import android.content.Context +import com.sulkta.straw.BuildConfig +import com.sulkta.straw.util.LogDump +import com.sulkta.straw.util.runCatchingCancellable +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +object LogShipper { + + /** Not a secret — only the bearer token is. */ + private const val INGEST_URL = "https://logs.sulkta.com/ingest" + + /** Server caps: 10k lines / 1 MB body. Stay well under both. */ + private const val MAX_LINES = 2_000 + private const val MAX_BODY_BYTES = 700_000 + + /** Hard cap on how long the crash path may delay process death. */ + private const val CRASH_JOIN_MS = 4_000L + + private const val PREFS = "straw_diag" + private const val KEY_DEVICE_ID = "device_id" + + /** + * Compile-time gate: builds without an injected token have no + * shipping at all — no network, no crash handler, no Settings row. + */ + val enabled: Boolean + get() = BuildConfig.STRAW_LOGS_TOKEN.isNotEmpty() + + // Short timeouts — this is telemetry, not payload. callTimeout is + // the absolute ceiling on the whole request so nothing here can + // hold a WorkManager-style wakelock pattern open. + private val http: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .callTimeout(10, TimeUnit.SECONDS) + .build() + } + + @Serializable + private data class Envelope( + @SerialName("device_id") val deviceId: String, + @SerialName("app_version") val appVersion: String, + @SerialName("sent_at") val sentAt: String, + val reason: String, + val lines: List, + ) + + /** + * Stable per-install id: `straw-`, minted once and + * persisted. Deliberately NOT ANDROID_ID/IMEI — it identifies an + * install for log correlation, never the hardware or the person. + * commit() (not apply()) because both call paths are already on a + * background thread and the crash path dies right after — an + * async write would lose the id and mint a new one per crash. + */ + private fun deviceId(context: Context): String { + val sp = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + sp.getString(KEY_DEVICE_ID, null)?.let { return it } + val id = "straw-${UUID.randomUUID()}" + sp.edit().putString(KEY_DEVICE_ID, id).commit() + return id + } + + private fun isoNowUtc(): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + .format(Date()) + + /** + * Manual trigger (Settings). Captures + scrubs the log ring and + * POSTs it. Returns true iff the server 2xx'd — the UI shows a + * toast either way; failures never throw. + */ + suspend fun send(context: Context, reason: String): Boolean = + withContext(Dispatchers.IO) { + if (!enabled) return@withContext false + runCatchingCancellable { + postLines( + context = context, + reason = reason, + lines = LogDump.captureScrubbedLinesBlocking(MAX_LINES), + ) + }.getOrDefault(false) + } + + /** + * Wrap the process-default uncaught-exception handler. Ship-then- + * chain: whatever happens in our path (including a throw), the + * previous handler ALWAYS runs, so the app still crashes/reports + * exactly as before. No-op without a token. + */ + fun installCrashHandler(context: Context) { + if (!enabled) return + val appContext = context.applicationContext + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + shipCrash(appContext, throwable) + } catch (_: Throwable) { + // Never let diagnostics mask the real crash. + } + previous?.uncaughtException(thread, throwable) + } + } + + @Volatile + private var crashShipStarted = false + + /** + * Crash-path send. The crashing thread may be main (network there + * throws NetworkOnMainThreadException) and is about to die either + * way, so the capture+POST runs on a dedicated worker thread and + * we join with a hard cap — the death is delayed at most + * [CRASH_JOIN_MS], never hung. + */ + private fun shipCrash(context: Context, throwable: Throwable) { + // One attempt per process — also breaks recursion if the ship + // path itself crashes on another thread mid-flight. + if (crashShipStarted) return + crashShipStarted = true + val worker = Thread { + try { + val trace = StringWriter().also { + throwable.printStackTrace(PrintWriter(it)) + }.toString() + .split('\n') + .take(400) + .map(LogDump::scrubLine) + val ring = LogDump.captureScrubbedLinesBlocking(MAX_LINES) + postLines( + context = context, + reason = "crash", + lines = ring + "--- uncaught exception ---" + trace, + ) + } catch (_: Throwable) { + // Best-effort only. + } + } + worker.name = "straw-crash-ship" + worker.isDaemon = true + worker.start() + try { + worker.join(CRASH_JOIN_MS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + /** + * Build the envelope and POST. `lines` MUST already be scrubbed + * (both call paths run everything through LogDump.scrubLine). + * Trims oldest-first to stay under the server's 1 MB cap — one + * bounded request, no chunking needed at our line cap. + */ + private fun postLines(context: Context, reason: String, lines: List): Boolean { + var budget = MAX_BODY_BYTES + val kept = ArrayList(lines.size) + for (i in lines.indices.reversed()) { + // +8 ≈ JSON quotes/comma + escaping slack per line. + val cost = lines[i].toByteArray(Charsets.UTF_8).size + 8 + if (cost > budget) break + budget -= cost + kept.add(lines[i]) + } + kept.reverse() + val envelope = Envelope( + deviceId = deviceId(context), + appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + sentAt = isoNowUtc(), + reason = reason, + lines = kept, + ) + val body = Json.encodeToString(envelope) + .toRequestBody("application/json; charset=utf-8".toMediaType()) + val request = Request.Builder() + .url(INGEST_URL) + .header("Authorization", "Bearer ${BuildConfig.STRAW_LOGS_TOKEN}") + .post(body) + .build() + return http.newCall(request).execute().use { it.isSuccessful } + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 6c96d72d0..35dee1e58 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -66,6 +66,7 @@ import com.sulkta.straw.data.Settings import com.sulkta.straw.data.ThemeMode import com.sulkta.straw.feature.dataimport.ImportResult import com.sulkta.straw.feature.dataimport.SettingsImport +import com.sulkta.straw.feature.diag.LogShipper import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.search.SearchViewModel import com.sulkta.straw.util.LogDump @@ -774,6 +775,39 @@ fun SettingsScreen() { Text(if (logDumping) "Exporting…" else "Export logs…") } + // Dogfood log shipping — only rendered in builds with an + // injected ingest token (LogShipper.enabled is compile-time + // false otherwise, so contributor builds show no phantom row). + if (LogShipper.enabled) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Or send the same scrubbed log dump straight to Kayos " + + "(logs.sulkta.com) — no share sheet. URLs, video ids, " + + "and anything token-shaped are redacted on-device first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + var logSending by remember { mutableStateOf(false) } + OutlinedButton( + enabled = !logSending, + onClick = { + logSending = true + scope.launch { + val ok = LogShipper.send(context, reason = "manual") + logSending = false + Toast.makeText( + context, + if (ok) "Logs sent — thanks!" else "Couldn't reach the log server", + Toast.LENGTH_LONG, + ).show() + } + }, + ) { + Text(if (logSending) "Sending…" else "Send logs to Kayos") + } + } + Spacer(modifier = Modifier.height(32.dp)) Text( "Import from NewPipe / Tubular", diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt index 159e7b8aa..ec922c4bd 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt @@ -96,10 +96,46 @@ object LogDump { } /** - * Pre-redact known credential-shaped substrings before they hit - * disk. Cheap line-level pass — adversarial-perfect would need a - * URL parser, but the regex approach catches every documented - * leak vector at zero allocation cost. + * Pull recent logcat, scrub every line, keep only the newest + * [maxLines]. BLOCKING — callers must already be off the main + * thread (LogShipper runs on Dispatchers.IO; the crash handler + * runs this on its own short-lived worker thread, where a suspend + * fun can't be awaited without an event loop). + * + * Best-effort by design: a logcat exec failure returns whatever + * was read (possibly empty) instead of throwing, so the crash + * shipper can still deliver the stacktrace. + */ + fun captureScrubbedLinesBlocking(maxLines: Int): List { + val ring = ArrayDeque() + runCatching { + val pid = Process.myPid() + val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid") + val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start() + proc.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + if (ring.size == maxLines) ring.removeFirst() + ring.addLast(line) + } + } + proc.waitFor() + } + // Scrub only the retained window — cheaper than scrubbing lines + // that get dropped, and nothing raw ever leaves this function. + return ring.map(::scrubLine) + } + + /** + * Pre-redact known credential- and identity-shaped substrings + * before they hit disk or the wire. Cheap line-level pass — + * adversarial-perfect would need a URL parser, but the regex + * approach catches every documented leak vector at zero + * allocation cost. + * + * BIAS: over-redaction. These lines leave the device (share sheet + * + LogShipper → logs.sulkta.com), so a false positive costs a + * little debug signal while a false negative leaks what someone + * watched. Anything URL-, token-, id-, or address-shaped goes. * * Public so error-handler call sites (PlayerScreen / VideoDetail * `playbackError`) can scrub Media3's `PlaybackException.message` @@ -109,28 +145,112 @@ object LogDump { */ fun scrubLine(line: String): String { var s = line - // Pre-signed googlevideo URLs: keep host visible, drop path+query. + // Pre-signed googlevideo URLs: keep the host label visible, drop + // host prefix + path + query (session-bound streaming creds). s = GOOGLEVIDEO_URL_RE.replace(s, "https://.googlevideo.com/") + // Credential-shaped headers / key-value pairs, wherever they + // appear: `Authorization: Bearer x`, `cookie: …`, `api_key=…`. + s = BEARER_RE.replace(s, "$1 ") + s = CRED_KV_RE.replace(s, "$1: ") // Long, distinctive token names — match anywhere. s = SIGNED_PARAM_LONG_RE.replace(s, "$1=") // Short single-letter / two-letter tokens — require `[?&]` // immediately before to avoid eating innocent counters. s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=") + // ANY remaining URL: keep scheme+host (routing/debug signal, + // not PII), drop path + query — that's where watch?v=…, + // /vi// thumbnails, search terms, etc. live. + s = URL_TAIL_RE.replace(s, "$1/") + // Schemeless YT links pasted into log messages ("youtu.be/xyz"). + s = SCHEMELESS_YT_RE.replace(s, "$1/") + // Emails. + s = EMAIL_RE.replace(s, "") + // YouTube channel + playlist ids (watch behavior). + s = CHANNEL_ID_RE.replace(s, "") + s = PLAYLIST_ID_RE.replace(s, "") + // Bare 11-char video-id-shaped tokens ("dQw4w9WgXcQ") logged + // outside any URL (rustypipe/strawcore do this). Requiring a + // digit/_/- inside the run spares ordinary 11-letter words; + // real ids virtually always contain one. Pure-alpha ids are + // still caught earlier when keyed (v=…) or inside a URL. + s = VIDEO_ID_CANDIDATE_RE.replace(s) { m -> + if (m.value.any { it.isDigit() || it == '_' || it == '-' }) "" else m.value + } + // Long high-entropy runs (hashes, visitor data, unlabeled + // tokens): 20+ [A-Za-z0-9_-] chars containing a digit. + s = LONG_TOKEN_RE.replace(s, "") + // IP addresses (v4 + v6 — v6 patterns are shaped so threadtime + // HH:MM:SS timestamps can never match). + s = IPV4_RE.replace(s, "") + s = IPV6_FULL_RE.replace(s, "") + s = IPV6_COMPRESSED_RE.replace(s, "") return s } private val GOOGLEVIDEO_URL_RE = Regex( """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""", ) + // `Authorization: Bearer ` and friends. The token charset is + // the RFC 6750 b64token alphabet. + private val BEARER_RE = Regex( + """\b(bearer)\s+[A-Za-z0-9._~+/=-]{4,}""", + RegexOption.IGNORE_CASE, + ) + // Credential-y names followed by `: value` or `= value` (optional + // closing quote for JSON `"token":"…"` shapes). Values may be + // quoted strings or a bare token. + private val CRED_KV_RE = Regex( + """\b(authorization|proxy-authorization|cookie|set-cookie|x-goog-[a-z\-]+|x-youtube-[a-z\-]+|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|client[-_]?secret|secret|token|auth|session[-_]?id|passw(?:or)?d|pwd|mnemonic|private[-_]?key)"?\s*[:=]\s*("[^"]*"|\S+)""", + RegexOption.IGNORE_CASE, + ) // Long tokens are unique enough to match anywhere. Short tokens - // (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before - // so we don't redact innocuous `n=42` counters from other libs. + // (n, mn, ms, … v, id, q) require `[?&]` immediately before so we + // don't redact innocuous `n=42` counters from other libs. `v`, + // `id`, `list`, `q`, `t` are the YouTube identity/search params. private val SIGNED_PARAM_LONG_RE = Regex( - """\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""", + """\b(signature|sparams|lsig|cpn|expire|pot|sig|key|videoId|video_id|docid|search_query|query)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) private val SIGNED_PARAM_SHORT_RE = Regex( - """([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""", + """([?&])(n|mn|ms|mo|pl|ip|ei|v|id|list|q|t)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) + // Any http(s) URL: capture scheme + host(+port), scrub the rest. + // Host charset can't contain `<`, so the googlevideo replacement + // above is never re-matched. + private val URL_TAIL_RE = Regex( + """(https?://[A-Za-z0-9.-]+(?::\d+)?)[/?#][^\s"'<>]*""", + ) + // youtu.be/… + youtube.com/… without a scheme. The lookbehind + // skips hosts already handled as part of a full URL (preceded by + // `/` or a subdomain dot). + private val SCHEMELESS_YT_RE = Regex( + """(?]*""", + ) + private val EMAIL_RE = Regex( + """[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""", + ) + private val CHANNEL_ID_RE = Regex( + """(? Date: Wed, 29 Jul 2026 11:00:06 -0700 Subject: [PATCH 09/10] ci: drop the broken gitleaks workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runs-on: ubuntu-latest + actions/checkout@v4 (a node action) can't run on our Forgejo runners — it has failed on every push since the crafting-table:local image was retired. Secret-scanning coverage is unaffected: the strict enforcement layer is the server-side pre-receive gitleaks hook on the bare repo (this workflow was only the redundant per-PR status), which still rejects any push introducing a secret. --- .forgejo/workflows/gitleaks.yml | 40 --------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 .forgejo/workflows/gitleaks.yml diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml deleted file mode 100644 index 10d7847f3..000000000 --- a/.forgejo/workflows/gitleaks.yml +++ /dev/null @@ -1,40 +0,0 @@ -# .forgejo/workflows/gitleaks.yml -# -# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at -# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered -# (task #295). -# -# Pairs with the pre-receive hook installed on every bare repo — that one is -# the strict enforcement layer (rejects the push); this one provides the -# per-PR red ✗ that branch-protection rules can require before merge. -# -# Layer 1 (this workflow): visible per-PR status, can be a required check. -# Layer 2 (pre-receive hook): strict enforcement at the server. -# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos. - -name: gitleaks - -on: - push: - pull_request: - -jobs: - scan: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Full history — gitleaks needs depth to scan a commit range. - fetch-depth: 0 - - - name: install gitleaks - run: | - curl -sSL -o gl.tar.gz \ - https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz - tar xzf gl.tar.gz gitleaks - chmod +x gitleaks - ./gitleaks version - - - name: scan - run: | - ./gitleaks detect --source . --no-banner --redact --verbose From 7cfe8fe91165de0970ab2714821915b7d90c0662 Mon Sep 17 00:00:00 2001 From: Cobb Date: Wed, 29 Jul 2026 21:52:51 -0700 Subject: [PATCH 10/10] ci: publish to fdroid on workflow_dispatch too, not just push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual dispatch is how we ship a strawcore-only change (strawcore is cloned fresh at build time) into an APK without a straw code change. The publish step was gated push-only, so a dispatch built + signer-verified the APK and then silently skipped publishing — a green run that shipped nothing. Allow the dispatch event to publish as well (push still requires ref=main). --- .forgejo/workflows/build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 928c3cc4a..7d2273bb5 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -131,7 +131,13 @@ jobs: # Lucy host forced-command. The host re-verifies the signer, re-signs # the fdroid index (keystore stays on Lucy), and rsyncs to Rackham. ---- - name: Publish to fdroid via Lucy host forced-command - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # Publish on a push to main OR a manual dispatch. A workflow_dispatch is + # how we ship a strawcore-only change (strawcore is cloned fresh at build + # time) into an APK without any straw code change — without the dispatch + # arm a dispatch builds + verifies the APK but silently never publishes. + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + github.event_name == 'workflow_dispatch' env: LUCY_KEY: ${{ secrets.STRAW_FDROID_LUCY_KEY }} # Publish target + its host-key are Forgejo secrets, NOT literals, so