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( + """(?