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_<vc>.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.
This commit is contained in:
parent
6d7e5508bb
commit
8d9ad3513f
5 changed files with 195 additions and 11 deletions
|
|
@ -359,6 +359,6 @@ const val STRAW_SDK_TARGET = 35
|
||||||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 91
|
const val STRAW_VERSION_CODE = 92
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-CY"
|
const val STRAW_VERSION_NAME = "0.1.0-CZ"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,29 @@ configure<ApplicationExtension> {
|
||||||
versionCode = STRAW_VERSION_CODE
|
versionCode = STRAW_VERSION_CODE
|
||||||
versionName = STRAW_VERSION_NAME
|
versionName = STRAW_VERSION_NAME
|
||||||
resValue("string", "app_name", "Straw")
|
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
|
// Explicit signing so CI / release builds reuse ONE keystore instead of
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import com.sulkta.straw.data.SearchCache
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.feature.dataimport.SettingsImport
|
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.feed.FeedRefreshScheduler
|
||||||
import com.sulkta.straw.feature.update.UpdateScheduler
|
import com.sulkta.straw.feature.update.UpdateScheduler
|
||||||
import com.sulkta.straw.feature.update.runUpdateCheck
|
import com.sulkta.straw.feature.update.runUpdateCheck
|
||||||
|
|
@ -90,6 +91,12 @@ class StrawApp : Application(), SingletonImageLoader.Factory {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.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
|
// Path C-7: route Rust `log::*` calls into Android logcat under tag
|
||||||
// "strawcore". Without this, every log line emitted from rustypipe /
|
// "strawcore". Without this, every log line emitted from rustypipe /
|
||||||
// strawcore is silently dropped, making playback regressions invisible
|
// strawcore is silently dropped, making playback regressions invisible
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.data.ThemeMode
|
import com.sulkta.straw.data.ThemeMode
|
||||||
import com.sulkta.straw.feature.dataimport.ImportResult
|
import com.sulkta.straw.feature.dataimport.ImportResult
|
||||||
import com.sulkta.straw.feature.dataimport.SettingsImport
|
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.feed.SubscriptionFeedViewModel
|
||||||
import com.sulkta.straw.feature.search.SearchViewModel
|
import com.sulkta.straw.feature.search.SearchViewModel
|
||||||
import com.sulkta.straw.util.LogDump
|
import com.sulkta.straw.util.LogDump
|
||||||
|
|
@ -774,6 +775,39 @@ fun SettingsScreen() {
|
||||||
Text(if (logDumping) "Exporting…" else "Export logs…")
|
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))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
Text(
|
Text(
|
||||||
"Import from NewPipe / Tubular",
|
"Import from NewPipe / Tubular",
|
||||||
|
|
|
||||||
|
|
@ -96,10 +96,46 @@ object LogDump {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-redact known credential-shaped substrings before they hit
|
* Pull recent logcat, scrub every line, keep only the newest
|
||||||
* disk. Cheap line-level pass — adversarial-perfect would need a
|
* [maxLines]. BLOCKING — callers must already be off the main
|
||||||
* URL parser, but the regex approach catches every documented
|
* thread (LogShipper runs on Dispatchers.IO; the crash handler
|
||||||
* leak vector at zero allocation cost.
|
* 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<String> {
|
||||||
|
val ring = ArrayDeque<String>()
|
||||||
|
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
|
* Public so error-handler call sites (PlayerScreen / VideoDetail
|
||||||
* `playbackError`) can scrub Media3's `PlaybackException.message`
|
* `playbackError`) can scrub Media3's `PlaybackException.message`
|
||||||
|
|
@ -109,28 +145,112 @@ object LogDump {
|
||||||
*/
|
*/
|
||||||
fun scrubLine(line: String): String {
|
fun scrubLine(line: String): String {
|
||||||
var s = line
|
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://<host>.googlevideo.com/<scrubbed>")
|
s = GOOGLEVIDEO_URL_RE.replace(s, "https://<host>.googlevideo.com/<scrubbed>")
|
||||||
|
// Credential-shaped headers / key-value pairs, wherever they
|
||||||
|
// appear: `Authorization: Bearer x`, `cookie: …`, `api_key=…`.
|
||||||
|
s = BEARER_RE.replace(s, "$1 <scrubbed>")
|
||||||
|
s = CRED_KV_RE.replace(s, "$1: <scrubbed>")
|
||||||
// Long, distinctive token names — match anywhere.
|
// Long, distinctive token names — match anywhere.
|
||||||
s = SIGNED_PARAM_LONG_RE.replace(s, "$1=<scrubbed>")
|
s = SIGNED_PARAM_LONG_RE.replace(s, "$1=<scrubbed>")
|
||||||
// Short single-letter / two-letter tokens — require `[?&]`
|
// Short single-letter / two-letter tokens — require `[?&]`
|
||||||
// immediately before to avoid eating innocent counters.
|
// immediately before to avoid eating innocent counters.
|
||||||
s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=<scrubbed>")
|
s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=<scrubbed>")
|
||||||
|
// ANY remaining URL: keep scheme+host (routing/debug signal,
|
||||||
|
// not PII), drop path + query — that's where watch?v=…,
|
||||||
|
// /vi/<id>/ thumbnails, search terms, etc. live.
|
||||||
|
s = URL_TAIL_RE.replace(s, "$1/<scrubbed>")
|
||||||
|
// Schemeless YT links pasted into log messages ("youtu.be/xyz").
|
||||||
|
s = SCHEMELESS_YT_RE.replace(s, "$1/<scrubbed>")
|
||||||
|
// Emails.
|
||||||
|
s = EMAIL_RE.replace(s, "<email>")
|
||||||
|
// YouTube channel + playlist ids (watch behavior).
|
||||||
|
s = CHANNEL_ID_RE.replace(s, "<channelId>")
|
||||||
|
s = PLAYLIST_ID_RE.replace(s, "<playlistId>")
|
||||||
|
// 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 == '-' }) "<videoId>" 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, "<token>")
|
||||||
|
// IP addresses (v4 + v6 — v6 patterns are shaped so threadtime
|
||||||
|
// HH:MM:SS timestamps can never match).
|
||||||
|
s = IPV4_RE.replace(s, "<ip>")
|
||||||
|
s = IPV6_FULL_RE.replace(s, "<ip>")
|
||||||
|
s = IPV6_COMPRESSED_RE.replace(s, "<ip>")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
private val GOOGLEVIDEO_URL_RE = Regex(
|
private val GOOGLEVIDEO_URL_RE = Regex(
|
||||||
"""https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""",
|
"""https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""",
|
||||||
)
|
)
|
||||||
|
// `Authorization: Bearer <jwt>` 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
|
// Long tokens are unique enough to match anywhere. Short tokens
|
||||||
// (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before
|
// (n, mn, ms, … v, id, q) require `[?&]` immediately before so we
|
||||||
// so we don't redact innocuous `n=42` counters from other libs.
|
// 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(
|
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,
|
RegexOption.IGNORE_CASE,
|
||||||
)
|
)
|
||||||
private val SIGNED_PARAM_SHORT_RE = Regex(
|
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,
|
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(
|
||||||
|
"""(?<![/@.\w])((?:www\.|m\.|music\.)?youtube\.com|youtu\.be)[/?][^\s"'<>]*""",
|
||||||
|
)
|
||||||
|
private val EMAIL_RE = Regex(
|
||||||
|
"""[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""",
|
||||||
|
)
|
||||||
|
private val CHANNEL_ID_RE = Regex(
|
||||||
|
"""(?<![A-Za-z0-9_-])UC[A-Za-z0-9_-]{22}(?![A-Za-z0-9_-])""",
|
||||||
|
)
|
||||||
|
private val PLAYLIST_ID_RE = Regex(
|
||||||
|
"""(?<![A-Za-z0-9_-])(?:PL|UU|LL|RD|OLAK5uy_)[A-Za-z0-9_-]{10,}(?![A-Za-z0-9_-])""",
|
||||||
|
)
|
||||||
|
private val VIDEO_ID_CANDIDATE_RE = Regex(
|
||||||
|
"""(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{11}(?![A-Za-z0-9_-])""",
|
||||||
|
)
|
||||||
|
private val LONG_TOKEN_RE = Regex(
|
||||||
|
"""(?<![A-Za-z0-9_-])(?=[A-Za-z_-]*\d)[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])""",
|
||||||
|
)
|
||||||
|
private val IPV4_RE = Regex(
|
||||||
|
"""(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)""",
|
||||||
|
)
|
||||||
|
// Full-form v6 needs 6-8 hex groups — `HH:MM:SS` (3 groups) can't
|
||||||
|
// match. Compressed form requires a literal `::`.
|
||||||
|
private val IPV6_FULL_RE = Regex(
|
||||||
|
"""(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{1,4}:){5,7}[0-9A-Fa-f]{1,4}(?![0-9A-Fa-f:])""",
|
||||||
|
)
|
||||||
|
private val IPV6_COMPRESSED_RE = Regex(
|
||||||
|
"""(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{1,4}:){1,6}:(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5})?(?![0-9A-Fa-f:])""",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue