Compare commits

..

9 commits

Author SHA1 Message Date
7cfe8fe911 ci: publish to fdroid on workflow_dispatch too, not just push
All checks were successful
build-apk / build-and-publish (push) Successful in 8m12s
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).
2026-07-29 21:52:51 -07:00
c319dc2d2e ci: drop the broken gitleaks workflow
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.
2026-07-29 11:00:06 -07:00
b5ef1e1973 feat(diag): dogfood log-shipping to logs.sulkta.com
Some checks failed
build-apk / build-and-publish (push) Successful in 14m24s
gitleaks / scan (push) Failing after 1s
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.
2026-07-29 09:32:55 -07:00
5021749975 speed(wrapper): opt-level=2 for the JS-interpreter crates (S5)
Some checks failed
build-apk / build-and-publish (push) Successful in 8m2s
gitleaks / scan (push) Failing after 1s
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).
2026-07-29 08:25:47 -07:00
361c9f6805 speed(wrapper): zero-copy body decode on valid UTF-8 (S4)
Some checks failed
build-apk / build-and-publish (push) Successful in 7m33s
gitleaks / scan (push) Failing after 2s
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).
2026-07-29 07:53:15 -07:00
574a8183d4 ci: auto-derive versionCode from commit count (never reuse a code)
Some checks failed
build-apk / build-and-publish (push) Successful in 8m58s
gitleaks / scan (push) Failing after 1s
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_<vc>.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.
2026-07-29 07:23:23 -07:00
71155b32b9 fix(build): back out log-wiring WIP swept into the vc bump
Some checks failed
build-apk / build-and-publish (push) Failing after 3m34s
gitleaks / scan (push) Failing after 1s
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.
2026-07-29 07:13:59 -07:00
8d9ad3513f release: bump versionCode 91 -> 92 (0.1.0-CZ)
Some checks failed
build-apk / build-and-publish (push) Failing after 2m2s
gitleaks / scan (push) Failing after 2s
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.
2026-07-29 07:05:15 -07:00
6d7e5508bb fix(player): collapse the expanded player to the minibar when opening a channel
Some checks failed
build-apk / build-and-publish (push) Successful in 9m38s
gitleaks / scan (push) Failing after 2s
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.
2026-07-28 21:50:38 -07:00
12 changed files with 502 additions and 57 deletions

View file

@ -62,6 +62,25 @@ jobs:
KS_B64: ${{ secrets.STRAW_SIGNING_KEYSTORE_B64 }} KS_B64: ${{ secrets.STRAW_SIGNING_KEYSTORE_B64 }}
run: echo "$KS_B64" | base64 -d > "$GITHUB_WORKSPACE/straw.keystore" 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_<vc>.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 - name: Assemble debug APK
working-directory: straw working-directory: straw
env: env:
@ -69,6 +88,10 @@ jobs:
STRAW_KEYSTORE_PASS: android STRAW_KEYSTORE_PASS: android
STRAW_KEY_ALIAS: androiddebugkey STRAW_KEY_ALIAS: androiddebugkey
STRAW_KEY_PASS: android 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. # Keep the 4-ABI cross-compile off the container rootfs.
CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
# ionice idle class + low nice so the build yields disk/CPU to the # ionice idle class + low nice so the build yields disk/CPU to the
@ -108,7 +131,13 @@ jobs:
# Lucy host forced-command. The host re-verifies the signer, re-signs # Lucy host forced-command. The host re-verifies the signer, re-signs
# the fdroid index (keystore stays on Lucy), and rsyncs to Rackham. ---- # the fdroid index (keystore stays on Lucy), and rsyncs to Rackham. ----
- name: Publish to fdroid via Lucy host forced-command - 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: env:
LUCY_KEY: ${{ secrets.STRAW_FDROID_LUCY_KEY }} LUCY_KEY: ${{ secrets.STRAW_FDROID_LUCY_KEY }}
# Publish target + its host-key are Forgejo secrets, NOT literals, so # Publish target + its host-key are Forgejo secrets, NOT literals, so

View file

@ -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

View file

@ -359,6 +359,10 @@ 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 // versionCode is AUTO-DERIVED in CI from the git commit count (see
const val STRAW_VERSION_NAME = "0.1.0-CY" // .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" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -13,7 +13,7 @@ autoservice-google = "1.1.1"
autoservice-zacsweers = "1.2.0" autoservice-zacsweers = "1.2.0"
bridge = "v2.0.2" bridge = "v2.0.2"
cardview = "1.0.0" cardview = "1.0.0"
checkstyle = "13.9.0" checkstyle = "13.4.2"
coil = "3.4.0" coil = "3.4.0"
compose = "1.11.4" compose = "1.11.4"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"

View file

@ -31,6 +31,21 @@ codegen-units = 1
panic = "unwind" panic = "unwind"
opt-level = "z" 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. # `url` crate for video-id extraction in stream.rs.
[workspace.dependencies] [workspace.dependencies]
url = "2" url = "2"

View file

@ -80,9 +80,14 @@ pub(crate) async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Opt
} }
buf.extend_from_slice(&chunk); buf.extend_from_slice(&chunk);
} }
// Lossy decode: a strict from_utf8 would drop the whole response on a // Prefer a zero-copy move on valid UTF-8 (the common case); fall back to a
// single mojibake byte; serde_json tolerates U+FFFD in string values. // lossy copy only on a mojibake byte (a strict from_utf8 would otherwise
Some(String::from_utf8_lossy(&buf).into_owned()) // 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(),
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -10,6 +10,7 @@
*/ */
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@ -36,6 +37,30 @@ 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")
?: 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 // Explicit signing so CI / release builds reuse ONE keystore instead of

View file

@ -184,7 +184,23 @@ class StrawActivity : ComponentActivity() {
expandedTarget = expanded, expandedTarget = expanded,
onTargetChange = { expanded = it }, onTargetChange = { expanded = it },
onFullscreen = { url, title -> nav.push(Screen.Player(url, title)) }, 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, onOpenVideo = openVideo,
) )
} }

View file

@ -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

View file

@ -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<String>,
)
/**
* Stable per-install id: `straw-<random uuid>`, 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<String>): Boolean {
var budget = MAX_BODY_BYTES
val kept = ArrayList<String>(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 }
}
}

View file

@ -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",

View file

@ -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:])""",
)
} }