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