Public-flip audit: scrub audit-ticket prefixes + LAN refs + tighten README

URLs → git.sulkta.com. Audit-ticket prefixes (SPEC §N, audit Track X, vc=N
audit-fix, FIX (audit ...), PORT DEVIATION) stripped from comments — technical
reasoning retained. Crafting-table LAN refs softened to 'Sulkta build host'.
README sheds marketing scaffolding + stale status tables.
This commit is contained in:
Cobb Hayes 2026-05-27 13:29:53 -07:00
parent 5a757bea23
commit 42cb945654
51 changed files with 261 additions and 378 deletions

View file

@ -38,11 +38,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Open YouTube URLs with Straw. Hosts here must stay in sync
with ALLOWED_YT_HOSTS in util/YtUrl.kt (canonical home as
of vc=42 — was previously inlined in StrawActivity.kt
under YT_HOSTS; drift was caught in the vc=34 function
audit, music.youtube.com etc. were accepted by code but
never offered by the launcher disambig). -->
with ALLOWED_YT_HOSTS in util/YtUrl.kt (canonical home).
Was previously inlined in StrawActivity.kt under YT_HOSTS;
the two lists drifted (music.youtube.com etc. accepted by
code but never offered by the launcher disambig), so the
canonical list lives in one place now. -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -63,11 +63,11 @@
</intent-filter>
</activity>
<!-- Phase M-2 / S: MediaSessionService for background audio + notification + lock-screen
controls. Marked NOT exported (audit CRIT-2): any installed app can otherwise
craft an Intent with the MediaSessionService action and drive playback from
attacker-controlled URLs. The intent-filter stays so the Media3 session router
can find the service within our own process. -->
<!-- MediaSessionService for background audio + notification + lock-screen
controls. Marked NOT exported: otherwise any installed app could
craft an Intent with the MediaSessionService action and drive playback
from attacker-controlled URLs. The intent-filter stays so the Media3
session router can find the service within our own process. -->
<service
android:name=".feature.player.PlaybackService"
android:exported="false"

View file

@ -43,8 +43,8 @@ import com.sulkta.straw.feature.settings.SettingsScreen
import kotlinx.coroutines.flow.MutableStateFlow
// Allowlist now lives in util/YtUrl.kt with extra hardening (scheme
// requirement, trailing-dot strip). Round-7 audit MED-4: prior shape
// duplicated the host set here and would drift away from the util.
// requirement, trailing-dot strip). The prior shape duplicated the
// host set here and would drift away from the util.
private val YT_URL_RE = Regex(
"https?://(?:www\\.|m\\.|music\\.)?(?:youtube(?:-nocookie)?\\.com/[A-Za-z0-9_/?=&\\-.%]+|youtu\\.be/[A-Za-z0-9_\\-]+)",
)

View file

@ -32,7 +32,7 @@ class StrawApp : Application() {
* in one launch doesn't cascade. CoroutineExceptionHandler so an
* uncaught throwable in a top-level launch doesn't crash the
* process on cold start (would otherwise hit the default handler
* even with SupervisorJob). Round-5 audit MED-3.
* even with SupervisorJob).
*/
private val appScope = CoroutineScope(
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, t ->
@ -74,7 +74,7 @@ class StrawApp : Application() {
Playlists.init(this)
Resume.init(this)
FeedEnrichment.init(this)
// vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache
// FeedCache (~225 KB) + SearchCache
// (~150 KB) JSON-decode at construction. Stash the
// applicationContext eagerly (cheap) so `get()` is callable
// anywhere; the actual store construction (and the disk
@ -83,7 +83,7 @@ class StrawApp : Application() {
// main thread.
FeedCache.init(this)
SearchCache.init(this)
// vc=36 audit CVE HIGH-5: sweepStale's deleteRecursively()
// sweepStale's deleteRecursively
// can walk ~256 MB if a previous import was LMK-killed
// mid-extraction. Strictly off the main thread.
appScope.launch {

View file

@ -295,7 +295,7 @@ private fun SubsPane(
LaunchedEffect(subs) { feedVm.refreshIfStale() }
// Filter + pagination state. hideWatched is sticky for the session
// (no SharedPreferences yet — easy to add if Cobb wants persistence).
// (no SharedPreferences yet — easy to add if persistence is wanted).
// visibleCount starts at PAGE_SIZE and grows by PAGE_SIZE every time
// the scroll passes ~5 items from the bottom of what's currently
// visible.
@ -324,7 +324,7 @@ private fun SubsPane(
}
}
// remember the page-slice so we don't allocate a new ArrayList on
// every recomposition (scroll hitch vc=67).
// every recomposition (scroll hitch).
val displayed = remember(filteredItems, visibleCount) {
filteredItems.take(visibleCount)
}
@ -373,7 +373,7 @@ private fun SubsPane(
Spacer(modifier = Modifier.height(16.dp))
// Show a slim error banner above cached items even if we have data —
// audit HIGH-7: previously a 401/429 looked identical to a successful
// previously a 401/429 looked identical to a successful
// refresh because the error chip was hidden whenever items != empty.
if (feed.error != null && feed.items.isNotEmpty()) {
Text(
@ -425,7 +425,7 @@ private fun SubsPane(
// (displayed.size, hasMore) was mutated BY this effect,
// which cancelled the snapshotFlow collector mid-stream
// and produced the "scrolled to bottom, nothing loads"
// bug from the vc=34 audit.
// bug from the audit.
//
// hasMore and filteredItems are read inside the
// snapshotFlow producer (not closed over from outside)
@ -598,7 +598,7 @@ private fun SubChip(
// width breaks the prior 2-line wrap mid-word ("NoCopyrightS
// / ounds", "DEFCONConfe / rence") — uglier than a clean
// "NoCopyrigh…". Centered text alignment so the ellipsis
// sits over the chip's icon column. vc=64.
// sits over the chip's icon column.
Text(
text = ch.name,
style = MaterialTheme.typography.labelSmall,

View file

@ -76,7 +76,7 @@ class EnrichmentStore(context: Context) {
)
val before = _entries.value
val next = _entries.updateAndGet { current ->
// Round-67 audit HIGH-4: short-circuit when the cached
// short-circuit when the cached
// value is already the same view+duration — re-enriching
// within TTL otherwise allocates a new Map every call
// and the `before !== next` guard never triggers, so a
@ -110,7 +110,7 @@ class EnrichmentStore(context: Context) {
private fun load(): Map<String, Enrichment> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyMap()
val loaded = json.decodeFromString<Map<String, Enrichment>>(s)
// Round-67 audit MED-6: prune TTL-expired entries on load
// prune TTL-expired entries on load
// so the store doesn't accumulate dead weight up to
// MAX_ENRICHMENTS over time. `Forever` TTL skips the prune.
val ttl = Settings.get().cacheTtl.value

View file

@ -40,7 +40,7 @@ class FeedCacheStore(context: Context) {
/**
* Snapshot of the disk cache, filtered by the user-configured TTL.
* Returns empty map if nothing saved or everything expired. vc=59
* Returns empty map if nothing saved or everything expired.
* Settings.cacheTtl.isForever short-circuits the filter; finite TTLs
* drop entries whose fetchedAt is older than (now - ttl).
*/
@ -73,7 +73,7 @@ object FeedCache {
* (and the ~225 KB JSON decode that happens at construction) is
* deferred until the first `get()` call. Lets Application.onCreate
* return quickly while every caller still gets a valid Store
* vc=36 audit HIGH-R3. Callers should access from a coroutine
* Callers should access from a coroutine
* (IO dispatcher) where the lazy construction cost is acceptable.
*/
fun init(context: Context) {

View file

@ -33,7 +33,7 @@ private const val KEY_WATCHES = "watches_v1"
private const val KEY_SEARCHES = "searches_v1"
/**
* Pre-vc=59 hard limits. Still used as the absolute upper bound when
* Earlier hard limits. Still used as the absolute upper bound when
* Settings.historyWatchesCap is CacheCap.Unlimited we don't want to
* allow truly-uncapped growth that could OOM SP on a hostile import.
* Any user-picked cap above this is silently floored to MAX_*_HARD.
@ -75,14 +75,14 @@ class HistoryStore(context: Context) {
/**
* Bulk import. Callers (currently SettingsImport) feed
* oldestnewest. Single SP write vc=34 audit flagged the
* oldestnewest. Single SP write audit flagged the
* per-row recordWatch in importHistory as a write-storm vector.
*
* Walks input newest-first (input is fed oldest-first), filters
* blanks + already-seen videoIds, prepends to current, then takes
* maxWatches(). Imports WIN over older current entries when the
* store is at the cap the vc=37 first cut silently discarded
* the whole import in that case (round-3 audit HIGH-1).
* store is at the cap the the first cut silently discarded
* the whole import in that case.
*
* Skips the SP write when the resulting list is identical (by
* reference equality after updateAndGet's no-op return) so a
@ -91,7 +91,7 @@ class HistoryStore(context: Context) {
/**
* Returns the number of fresh items actually folded into the
* store on this call (counts new videoIds; duplicates of
* already-recorded entries don't count). Round-4 audit HIGH-7
* already-recorded entries don't count).
* SettingsImport previously reported `size_after - size_before`
* which lies when the store was at maxWatches() (post-state can
* be 50 = pre-state even when 20 imports landed and 20 older
@ -104,7 +104,7 @@ class HistoryStore(context: Context) {
val next = _watches.updateAndGet { current ->
// Reset the counter inside the CAS lambda so a retry
// doesn't accumulate across attempts — same shape as
// SubscriptionsStore.addAll's vc=37 round-3 fix.
// SubscriptionsStore.addAll's round-3 fix.
counter.set(0)
val seen = HashSet<String>(current.size + items.size)
current.forEach { seen.add(it.videoId) }
@ -134,9 +134,8 @@ class HistoryStore(context: Context) {
/**
* Bulk import for search history. Same pattern as
* recordAllWatches single SP write regardless of input size.
* vc=37 round-3 audit CVE-MED-6: SettingsImport.importHistory was
* calling recordSearch per row, producing N SP writes on a
* potentially-100k-row import.
* SettingsImport.importHistory previously called recordSearch per
* row, producing N SP writes on a potentially-100k-row import.
*/
/**
* Returns the number of fresh queries actually folded into the

View file

@ -69,7 +69,6 @@ class PlaylistsStore(context: Context) {
* addItem() in a loop both write SP, and addItem walks every
* playlist linearly per insert. A 100-playlist × 100-items
* NewPipe export was ~10,001 SP commits + ~10M comparisons.
* Round-4 audit HIGH-2.
*/
fun importPlaylist(name: String, items: List<PlaylistItem>): Playlist {
val stampNow = System.currentTimeMillis()

View file

@ -38,7 +38,7 @@ private const val PREFS = "straw_resume_positions"
private const val KEY_POSITIONS = "positions_v1"
/**
* Pre-vc=59 hard cap. Now a ceiling rather than a fixed value: the
* Earlier hard cap. Now a ceiling rather than a fixed value: the
* user-picked cap from Settings.resumePositionsCap is silently floored
* to this so even "Unlimited" doesn't OOM SP. Bigger ceiling here
* than HistoryStore because resume entries are tiny (~50 bytes each)
@ -97,7 +97,7 @@ class ResumePositionsStore(context: Context) {
)
val before = _positions.value
val next = _positions.updateAndGet { current ->
// Round-67 audit HIGH-6: short-circuit value-equality
// short-circuit value-equality
// a 5s poll tick that finds the same (position, duration,
// wall-time) for an existing entry returns `current`
// unchanged so the outer `next !== before` guard
@ -117,7 +117,7 @@ class ResumePositionsStore(context: Context) {
val withEntry = current + (videoId to entry)
// Skip sort+associate when we're under the cap (the
// common case at default 500). Sort is O(n log n);
// associate allocates another map. Round-67 audit HIGH-6.
// associate allocates another map.
if (withEntry.size > maxResumes()) {
// Drop oldest by lastWatchedAt — newcomers always land
// because the entry we just added is by definition the
@ -133,8 +133,7 @@ class ResumePositionsStore(context: Context) {
if (next !== before) {
// JSON encode + SP write off Main — encoding 100k entries
// would be ~50-100 ms on a low-end device, and the 5s
// captureResumePosition poll runs on Main. Round-67
// audit HIGH-6.
// captureResumePosition poll runs on Main.
StrawApp.globalScope.launch(Dispatchers.IO) {
sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply()
}

View file

@ -12,7 +12,7 @@
* = ~150 KB worst case.
*
* Backed by a MutableStateFlow loaded once at construction
* record()/load() are atomic against concurrent calls. vc=36 audit
* record/load are atomic against concurrent calls. audit
* B5: the prior load()edit()write() pattern would clobber a
* concurrent record() with whichever happened to persist last.
*

View file

@ -72,7 +72,7 @@ enum class AutoUpdateInterval(val label: String) {
/**
* User-facing cache caps. Each store's hard limit is the cap's value;
* `Int.MAX_VALUE` means "unlimited" (the store grows without trimming).
* Defaults match the pre-vc=59 hardcoded constants so existing data
* Defaults match the earlier hardcoded constants so existing data
* keeps the same shape until the user picks something different.
*/
enum class CacheCap(val label: String, val value: Int) {
@ -221,7 +221,7 @@ class SettingsStore(context: Context) {
/**
* Cached "latest version seen on fdroid" 0 / "" while none known
* or while caught-up. Lets SettingsScreen show "vc=55 available"
* or while caught-up. Lets SettingsScreen show "an update available"
* without re-polling.
*/
private val _latestKnownVc = MutableStateFlow(
@ -244,7 +244,7 @@ class SettingsStore(context: Context) {
* "#shorts" / "#Shorts" / "(shorts)" which most short uploaders
* include.
* Filter is best-effort a hand-tagged short with a clean title
* in the subs feed will slip through until vc=57 plumbs an
* in the subs feed will slip through until a future build plumbs an
* isShort flag through strawcore-core.
*/
private val _hideShorts = MutableStateFlow(
@ -258,7 +258,7 @@ class SettingsStore(context: Context) {
* takes effect immediately (next write trims to the new cap; reads
* are unbounded since they're already in memory).
*
* Defaults match the pre-vc=59 hardcoded constants so first-launch
* Defaults match the earlier hardcoded constants so first-launch
* behavior is unchanged from prior versions.
*/
private val _historyWatchesCap = MutableStateFlow(
@ -308,10 +308,9 @@ class SettingsStore(context: Context) {
}
// Atomic + idempotent. Capture before-state, update in-memory,
// skip the SP write when the value didn't actually change. Round-5
// audit LOW-1 / MED-2: the prior shape used
// `updateAndGet { r } == r` which is unconditionally true (lambda
// ignores prior) — dead code that confused readers.
// skip the SP write when the value didn't actually change. The
// prior shape used `updateAndGet { r } == r` which is unconditionally
// true (the lambda ignores prior) — dead code that confused readers.
fun setMaxResolution(r: MaxResolution) {
val before = _maxResolution.value
if (before == r) return

View file

@ -66,7 +66,7 @@ class SubscriptionsStore(context: Context) {
/**
* Bulk-add. Single persist instead of N. Per-call `toggle()` was
* O() + N SP writes, which the vc=34 security audit flagged as
* O() + N SP writes, which the security audit flagged as
* a DoS vector for hostile NewPipe-export imports. Single linear
* scan to dedup, one persist regardless of input size. Returns the
* count of NEW (not previously-subscribed) channels added so the
@ -76,8 +76,8 @@ class SubscriptionsStore(context: Context) {
// Count NEW refs by checking each input URL against the
// current state's pre-image inside the CAS lambda. Captures
// exactly the additions this call made — concurrent
// toggle()s that race the CAS don't inflate the count (vc=37
// round-3 audit HIGH-2/CVE-2). The counter lives in an
// toggles that race the CAS don't inflate the count (
// ). The counter lives in an
// AtomicInteger so each lambda re-run resets it correctly.
val counter = java.util.concurrent.atomic.AtomicInteger(0)
val next = _subs.updateAndGet { state ->

View file

@ -80,7 +80,7 @@ fun ChannelScreen(
// the screen recomposes once with A's state before vm.load(B)
// resets it. Without this branch we'd render channel A's banner /
// name / videos under URL B. Same shape as VideoDetailScreen's
// gate. Round-69 audit HIGH-1.
// gate.
state.loadedUrl != channelUrl -> Box(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentAlignment = Alignment.Center,
@ -218,8 +218,8 @@ private fun ChannelVideoRow(
// Don't repeat duration here — VideoThumbnail's
// bottom-right badge already shows it. Add the upload
// date so the row reads 'N views · 2 days ago' the way
// YT renders it. vc=65 — Cobb caught the duplicate
// duration + missing date on the channel page.
// YT renders it. The earlier row was duplicating duration
// and missing the upload date on the channel page.
val meta = buildString {
if (item.viewCount > 0) append("${formatCount(item.viewCount)} views")
if (item.uploadDateRelative.isNotBlank()) {

View file

@ -36,7 +36,7 @@ data class ChannelUiState(
* frame before vm.load(B) clears it. Without this field, any
* caller that derives "this is the channel we want" from
* `state.name` (or other display fields) is reading channel A's
* data while believing it's B. Round-68 audit MED-4.
* data while believing it's B.
*/
val loadedUrl: String? = null,
)
@ -47,20 +47,20 @@ class ChannelViewModel : ViewModel() {
// Track the active load coroutine — same shape as
// VideoDetailViewModel. Rapid channel switches no longer race;
// the late-arriving older fetch is cancelled. Round-4 audit
// HIGH-2 / MED-1.
// the late-arriving older fetch is cancelled.
// / MED-1.
private var inFlight: Job? = null
fun load(channelUrl: String) {
// Snapshot _ui once so the two reads agree. Round-68 audit MED-4.
// Snapshot _ui once so the two reads agree.
val snap = _ui.value
if (snap.loadedUrl == channelUrl && snap.videos.isNotEmpty()) return
// Round-5 audit MED-3: extractor-emitted uploaderUrl can be
// extractor-emitted uploaderUrl can be
// attacker-controlled if the YT response is poisoned upstream.
// Refuse non-YT hosts at the entry point so we don't even
// issue a network call to evil.com via strawcore. Round-6
// audit HIGH-1: also cancel inFlight on rejection so a
// still-resolving prior load can't clobber the error banner.
// issue a network call to evil.com via strawcore. Also cancel
// inFlight on rejection so a still-resolving prior load can't
// clobber the error banner.
if (!isAllowedYtUrl(channelUrl)) {
inFlight?.cancel()
inFlight = null
@ -110,8 +110,7 @@ class ChannelViewModel : ViewModel() {
loading = false,
// Scrub before storing — UniFFI/Rust exceptions
// can embed full signed googlevideo URLs in the
// message (NetworkError::Recaptcha { url }). vc=37
// round-3 audit CVE-1.
// message (NetworkError::Recaptcha { url }).
error = com.sulkta.straw.util.LogDump.scrubLine(
t.message ?: t.javaClass.simpleName,
),

View file

@ -103,8 +103,7 @@ object SettingsImport {
private const val YT_SERVICE_ID = 0
// The allowlist itself lives in util.YtUrl now — VideoDetailViewModel
// also gates auto-channelInfo + recordWatch through it. Round-4
// audit HIGH-4 / HIGH-5.
// also gates auto-channelInfo + recordWatch through it.
private fun isAllowedYtUrl(url: String): Boolean =
com.sulkta.straw.util.isAllowedYtUrl(url)
@ -113,7 +112,7 @@ object SettingsImport {
// runInner is suspend (it switches to NonCancellable for
// cleanup). Plain runCatching would swallow a user-back
// CancellationException and surface it as a normal
// failure with a misleading banner. Round-6 audit HIGH-2.
// failure with a misleading banner.
com.sulkta.straw.util.runCatchingCancellable {
runInner(context, zipUri)
}
@ -121,7 +120,7 @@ object SettingsImport {
/**
* Sweep stale import work-dirs left behind by a previous run that
* was killed mid-extraction. CRIT from the vc=34 security audit:
* was killed mid-extraction. CRIT from the security audit:
* a force-killed import leaves the user's full newpipe.db sitting
* in cacheDir indefinitely. StrawApp.onCreate calls this on every
* cold start.
@ -203,7 +202,7 @@ object SettingsImport {
// Reject duplicate entries — a malicious zip
// can put a benign db first and a hostile
// second; ZipInputStream walks in order and
// would overwrite. Round-6 audit MED-5.
// would overwrite.
if (dbFile != null) {
warnings += "duplicate newpipe.db in archive — aborting"
return null to null
@ -322,8 +321,7 @@ object SettingsImport {
openDb(dbFile).use { db ->
val playlistRows = mutableListOf<Pair<Long, String>>()
// Hard caps so a malicious export with millions of rows
// doesn't walk an unbounded cursor into memory. Round-6
// audit MED-3.
// doesn't walk an unbounded cursor into memory.
db.rawQuery("SELECT uid, name FROM playlists LIMIT 256", null).use { c ->
while (c.moveToNext()) {
val uid = c.getLong(0)
@ -362,7 +360,7 @@ object SettingsImport {
// instead of (1 create + N addItem) writes. Old shape
// produced ~10k SP commits on a 100×100 export, plus
// O(N²) work in addItem's per-call linear scan over
// every playlist. Round-4 audit HIGH-2.
// every playlist.
store.importPlaylist(name, items)
playlistsAdded++
itemsAdded += items.size
@ -390,7 +388,7 @@ object SettingsImport {
openDb(dbFile).use { db ->
// Search history — feed oldest first so the store ends up with
// the most-recent on top after its own dedup + take(MAX).
// Stage + bulk-write — vc=37 round-3 audit CVE MED-6:
// Stage + bulk-write —:
// per-row recordSearch was N SP writes on potentially
// 100k+ rows. The SELECT also lacked a LIMIT; added now.
val stagedSearches = mutableListOf<String>()
@ -458,7 +456,7 @@ object SettingsImport {
// recordAllWatches / recordAllSearches return the real
// added count (counts fresh videoIds / queries that landed,
// ignoring duplicates and pre-saturated-store truncation).
// Round-4 audit HIGH-7 / MED-2 — previous size_after -
// / MED-2 — previous size_after
// size_before reported 0 when the store was already at cap
// even when 20 fresh imports actually landed.
return HistResult(
@ -496,7 +494,6 @@ object SettingsImport {
// changed something. Prior shape counted every
// observed key, inflating the import summary to
// "12 settings applied" when only 2 changed.
// Round-6 audit MED-2.
if (want != have) {
settings.toggle(cat)
applied++

View file

@ -264,8 +264,8 @@ fun VideoDetailScreen(
// vm.load(B)'s reset propagates. Without this gate, the
// InlinePlayer's LaunchedEffect would fire with
// streamUrl=B but resolved=A's URLs and play A under
// B's chrome (Cobb-reported 2026-05-26: detail page
// shows new video, audio is the old one).
// B's chrome — symptom is the detail page showing the
// new video while the audio is still the old one.
if (state.loadedUrl != streamUrl) return@Column
// Player surface — edge-to-edge, NewPipe/YouTube style.
// Lives outside the 16dp horizontal padding so the
@ -485,7 +485,7 @@ fun VideoDetailScreen(
}
// PiP into nothing isn't useful — bail with a
// Toast if there's no controller / no resolved
// playback to push into it. vc=34 audit Q-13.
// playback to push into it.
val c = controller
val r = state.resolved
if (c == null || r == null) {
@ -715,7 +715,7 @@ private fun RelatedRow(
// the uploader name on each row — it's implicit. Skip
// empty pieces with the leading-separator dance so we
// never end up with " · viewCount" or trailing dots.
// vc=64 — Cobb caught the empty metadata line on
// Earlier shape was leaving an empty metadata line on
// More-from-channel rows.
val meta = buildString {
if (item.uploader.isNotBlank()) append(item.uploader)
@ -770,7 +770,7 @@ private fun InlinePlayer(
// retryVersion lets the user manually re-fire setPlayingFrom after
// a playback error. Without it, the screen used to lock into the
// thumbnail+spinner branch once NowPlaying.clear() fired from
// onPlayerError. vc=62 audit BUG-2.
// onPlayerError.
val resolved = state.resolved
var retryVersion by remember(streamUrl) { mutableIntStateOf(0) }
LaunchedEffect(controller, resolved, streamUrl, retryVersion) {
@ -794,12 +794,11 @@ private fun InlinePlayer(
val listener = object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
// Scrub the message — Media3's HttpDataSource exceptions
// include the full signed URL in .message. vc=36 audit
// CVE HIGH-1.
// include the full signed URL in.message.
val raw = error.message ?: "(no message)"
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
// Clear NowPlaying so the minibar drops the dead
// session. vc=36 audit MED-3.
// session.
NowPlaying.clear()
}
}
@ -834,7 +833,7 @@ private fun InlinePlayer(
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = {
// Clear the error AND nudge the LaunchedEffect to
// re-attempt setPlayingFrom. vc=62 audit BUG-2 —
// re-attempt setPlayingFrom.
// without this the screen used to lock on the
// error forever after NowPlaying.clear().
playbackError = null

View file

@ -100,7 +100,7 @@ data class VideoDetailUiState(
* vm.load(B) clears it. Without this field, the InlinePlayer's
* setPlayingFrom would fire with streamUrl=B but resolved=A's
* playback URLs claiming NowPlaying with B's streamUrl but
* playing A's video under it. vc=63 audit.
* playing A's video under it. audit.
*/
val loadedUrl: String? = null,
)
@ -112,7 +112,7 @@ class VideoDetailViewModel : ViewModel() {
// Track the active load coroutine so a rapid tap to a different video
// cancels the prior fetch; otherwise a slow-to-finish older load
// overwrites the newer state and the player ends up streaming A while
// the detail UI shows B. Round-4 audit HIGH-2.
// the detail UI shows B.
private var inFlight: Job? = null
fun load(streamUrl: String) {
@ -123,11 +123,11 @@ class VideoDetailViewModel : ViewModel() {
if (snap.loadedUrl == streamUrl && snap.detail != null) return
// Same YT-host gate as ChannelViewModel — covers the case
// where a tap on a poisoned related-card lands here.
// Round-5 audit MED-3. Round-6 audit HIGH-1: cancel any
// cancel any
// in-flight load on rejection too — otherwise the
// late-arriving prior-job's fence still PASSES (loadedUrl
// wasn't moved) and clobbers the "Unsupported URL" error
// banner. round-67 audit HIGH-7: also set loadedUrl on this
// banner.: also set loadedUrl on this
// path so the gate reads coherently for any caller that
// checks _ui.value.loadedUrl on the rejected path.
if (!isAllowedYtUrl(streamUrl)) {
@ -155,12 +155,11 @@ class VideoDetailViewModel : ViewModel() {
// Move SP write off the main coroutine — recordWatch
// JSON-encodes the watch list (up to 50 entries) +
// sp.edit().apply(). Small but synchronous; vc=36
// sp.edit.apply. Small but synchronous;
// audit Q9. Only record when the resolved URL passes
// the YT allowlist — otherwise extractor-emitted
// non-YT URLs (poisoned related/moreFromChannel) end
// up in Recent Watches and survive process death.
// Round-4 audit HIGH-5.
if (isAllowedYtUrl(streamUrl)) {
withContext(Dispatchers.IO) {
runCatchingCancellable {
@ -216,9 +215,9 @@ class VideoDetailViewModel : ViewModel() {
// Gate the auto-fetch behind the same YT-host allowlist
// we apply to imports: a poisoned uploaderUrl from the
// extractor would otherwise trigger an arbitrary-host
// network call. Round-4 audit HIGH-4.
// network call.
//
// Round-69 audit MED-3: validate once and persist the
// validate once and persist the
// SAFE value into VideoDetail.uploaderUrl so downstream
// consumers (NowPlaying → PlaybackService autoplay,
// queue, etc.) inherit the validated string instead
@ -246,7 +245,7 @@ class VideoDetailViewModel : ViewModel() {
// extractor surfaces the URL string verbatim
// and a poisoned channel page could ship
// `data:image/svg+xml,<svg>...<script>` or
// `javascript:`. Round-68 audit MED-3.
// `javascript:`.
val fresh = ch.avatar
val safeFresh = if (!fresh.isNullOrBlank() &&
(fresh.startsWith("https://") || fresh.startsWith("http://"))) {
@ -285,8 +284,8 @@ class VideoDetailViewModel : ViewModel() {
// Fence the terminal write against late-arriving older
// loads: if a subsequent load(B) cancelled this one but
// we resolved past the suspension point, drop our
// result rather than clobber B's state. Round-4 audit
// HIGH-2. Round-67 audit HIGH-7: single source of
// result rather than clobber B's state.
// : single source of
// truth — read loadedUrl from _ui rather than a
// shadowing field.
if (_ui.value.loadedUrl != streamUrl) return@launch
@ -298,8 +297,7 @@ class VideoDetailViewModel : ViewModel() {
title = title,
uploader = uploader,
// Use the allowlist-validated value, not
// the raw extractor field. Round-69 audit
// MED-3.
// the raw extractor field.
uploaderUrl = uploaderUrl,
uploaderAvatar = channelExtras.avatar,
uploaderSubscriberCount = channelExtras.subscriberCount,

View file

@ -67,8 +67,7 @@ object Downloader {
DownloadManager.Request(Uri.parse(url))
// Sanitized title — bidi-overrides and control chars
// in extractor output would otherwise render in
// DownloadsScreen's row title. vc=37 round-3 audit
// CVE MED-7.
// DownloadsScreen's row title.
.setTitle(safeTitle)
.setDescription("Straw — ${kind.name.lowercase()}")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)

View file

@ -98,7 +98,7 @@ fun DownloadsScreen() {
// DownloadManager.query() is a ContentResolver IPC + a
// SQLite cursor walk — disk I/O on the main coroutine
// visibly stutters on devices with hundreds of historical
// downloads. Round-4 audit MED-2.
// downloads.
val fresh = withContext(Dispatchers.IO) { queryDownloads(context) }
rows = fresh
val active = fresh.any {

View file

@ -30,7 +30,6 @@ object FeedRefreshScheduler {
return
}
// WorkManager 15-minute periodic floor — see UpdateScheduler.
// Round-67 audit MED-4.
val request = PeriodicWorkRequestBuilder<FeedRefreshWorker>(
s.bgFeedRefreshInterval.value.minutes.coerceAtLeast(15L),
TimeUnit.MINUTES,

View file

@ -7,7 +7,7 @@
* persists the results into FeedCacheStore. Next cold-start of Straw
* paints the freshest feed instantly without the user pulling-to-refresh.
*
* The vc=56 RSS swap dropped per-channel fetch time from ~500ms to
* The the RSS swap dropped per-channel fetch time from ~500ms to
* ~50-150ms, so a 50-sub refresh now costs ~1-2s total small enough to
* run quietly in the background on the user's chosen cadence.
*
@ -47,7 +47,7 @@ class FeedRefreshWorker(
// parse failures. The former wants Result.retry() so
// WorkManager re-attempts within the current window with
// exponential backoff; without this, a 30-second offline blip
// eats a full 6-hour refresh cycle. Round-68 audit HIGH-1:
// eats a full 6-hour refresh cycle.:
// earlier `IOException` catch was dead code — UniFFI throws
// `uniffi.strawcore.StrawcoreException.Network` for transport
// errors, which does NOT extend IOException.
@ -60,7 +60,6 @@ class FeedRefreshWorker(
// reCAPTCHA challenges clear on their own minutes-to-hours
// later. Treating these as permanent eats a full refresh
// cycle the same way the pre-fix IOException catch did.
// Round-69 audit MED-1.
strawLogW("FeedRefresh") { "YT challenge, retrying: ${e.message}" }
return Result.retry()
} catch (e: Throwable) {

View file

@ -57,7 +57,7 @@ class SubscriptionFeedViewModel : ViewModel() {
// Seed loading=true: the init block always either hydrates from
// disk or fires a refresh, so the user should see the spinner
// (or cached content under it) rather than a one-frame flash of
// empty. vc=36 audit HIGH-R5.
// empty.
private val _ui = MutableStateFlow(SubscriptionFeedUiState(loading = true))
val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow()
@ -76,7 +76,7 @@ class SubscriptionFeedViewModel : ViewModel() {
init {
// Hydrate from disk and immediately render the cached items so
// the Subs tab paints before the network round-trip resolves.
// vc=34 audit CRIT: previously this ran synchronously on the
// previously this ran synchronously on the
// main thread at VM construction, blocking the first compose
// pass on a ~225 KB Json.decodeFromString.
viewModelScope.launch {
@ -86,20 +86,19 @@ class SubscriptionFeedViewModel : ViewModel() {
// putIfAbsent (not putAll) — refresh() may have started
// populating fresh entries during our IO suspension; we
// must not overwrite those with disk-stale values.
// vc=37 round-3 audit CVE-3.
saved.forEach { (url, entry) -> channelCache.putIfAbsent(url, entry) }
val channels = Subscriptions.get().subs.value
if (channels.isNotEmpty()) {
pruneCacheToSubs(channels)
val savedTs = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L
// Compute the merge off-Main first (round-67 audit
// HIGH-1) — flatMap + regex + sort on hydration was
// Compute the merge off-Main first.
// FlatMap + regex + sort on hydration was
// running on Main and could add ~10-20 ms to cold
// start on a slow phone.
val hydrated = withContext(Dispatchers.Default) { mergeFromCache(channels) }
// _ui.update so a concurrent refresh()'s state write
// doesn't race with this copy. vc=37 round-3 audit
// HIGH-4. Only advance lastFetchedAt — never regress.
// doesn't race with this copy.
// Only advance lastFetchedAt — never regress.
_ui.update {
it.copy(
items = hydrated,
@ -119,7 +118,7 @@ class SubscriptionFeedViewModel : ViewModel() {
private val perChannelTimeoutMs = 10_000L
/**
* Parallel network fetches. Cranked from 12 50 in vc=56 alongside
* Parallel network fetches. Cranked from 12 50 previously alongside
* the RSS-feed swap. Each fetch is now a ~5-15KB Atom XML payload
* instead of a ~150KB InnerTube channel-page scrape Tokio's
* `buffer_unordered` inside `subscription_feed()` handles >50
@ -146,12 +145,12 @@ class SubscriptionFeedViewModel : ViewModel() {
* still kill the *previous* enrichment so we don't pile up
* overlapping fan-outs (8-wide × N overlapping refreshes blows the
* concurrency budget). Tracked here, cancelled in the same places
* `inFlight` is. Round-67 audit HIGH-2/3/8.
* `inFlight` is./3/8.
*/
private var enrichJob: Job? = null
fun refreshIfStale() {
// Skip if a refresh is already in flight. vc=36 audit CRIT-R1:
// Skip if a refresh is already in flight.:
// SubsPane's LaunchedEffect(subs) re-fires every time
// Subscriptions.updateAvatar emits a fresh list reference (which
// fetchChannelInto does opportunistically per channel). Without
@ -176,9 +175,9 @@ class SubscriptionFeedViewModel : ViewModel() {
// channelCache when the user unsubscribes from the last
// channel; we'd clear() then immediately repopulate with
// phantom entries when the prior fetchChannelInto resolved.
// vc=37 round-3 audit HIGH-3. Also kill any in-flight
// Also kill any in-flight
// enrichment fan-out so we don't end up with N overlapping
// enrich jobs piling up under spam-refresh — round-67 HIGH-8.
// enrich jobs piling up under spam-refresh
inFlight?.cancel()
enrichJob?.cancel()
val channels = Subscriptions.get().subs.value
@ -199,7 +198,7 @@ class SubscriptionFeedViewModel : ViewModel() {
// force=true (user tapped Refresh): fan out across
// every subscribed channel. force=false (the auto
// refreshIfStale path): only the stale entries.
// Round-4 audit HIGH-8 — previously refresh() also
// — previously refresh also
// filtered to stale-only, so a user-initiated tap
// 5min after the last refresh was a silent no-op.
channels
@ -215,8 +214,8 @@ class SubscriptionFeedViewModel : ViewModel() {
// Move flatMap + per-item regex + sort off Main —
// viewModelScope.launch runs on Main by default and
// mergeFromCache is non-trivial on a 500-item merge.
// Round-67 audit HIGH-1. ensureActive() AFTER the
// withContext hop is round-68 audit HIGH-2: a
// ensureActive AFTER the
// withContext hop is: a
// synchronous Default body doesn't observe
// cancellation until the next suspension; without
// this check, a cancel that landed mid-merge would
@ -231,7 +230,7 @@ class SubscriptionFeedViewModel : ViewModel() {
lastFetchedAt = System.currentTimeMillis(),
)
}
// vc=66 — hybrid backfill. RSS-fed items have
// hybrid backfill. RSS-fed items have
// viewCount=0 + durationSeconds=0; kick a bounded
// background job that calls enrichFeedItem for the
// top items and pumps a fresh _ui emit when done.
@ -253,8 +252,7 @@ class SubscriptionFeedViewModel : ViewModel() {
// Re-throw cancellation so spam-tapping Refresh (or
// toggling cache OFF→ON during a refresh) doesn't
// surface a "refresh failed: StandaloneCoroutineCancelled"
// banner above the cached items. vc=37 round-3 audit
// function-correctness HIGH-1.
// banner above the cached items.
if (t is CancellationException) throw t
_ui.update {
it.copy(
@ -269,7 +267,7 @@ class SubscriptionFeedViewModel : ViewModel() {
}
private suspend fun fetchChannelInto(ch: ChannelRef) {
// vc=56: swapped uniffi.strawcore.channelInfo() (~500ms each,
// swapped uniffi.strawcore.channelInfo (~500ms each,
// full InnerTube page scrape with JS eval) for the RSS feed
// (~50-150ms each, tiny Atom XML). Same fan-out architecture,
// ~5-10× faster. Avatar backfill is skipped on this path —
@ -319,14 +317,13 @@ class SubscriptionFeedViewModel : ViewModel() {
// Pure read. Caller is responsible for calling pruneCacheToSubs
// beforehand when channel-set changes matter — split here
// because the prior version's "merge" name hid a side-effecting
// prune that violated single-responsibility (vc=36 audit
// HIGH-R7).
// prune that violated single-responsibility (.
//
// Pre-compute recencyScore once per item — vc=35 audit
// Pre-compute recencyScore once per item audit
// MED-Q15: sortedWith's comparator was invoking the regex
// twice per pair, so ~1800 regex matches on a 900-item merge.
//
// vc=66 — overlay FeedEnrichment data on each item so RSS-fed
// overlay FeedEnrichment data on each item so RSS-fed
// rows (viewCount=0, durationSeconds=0) get backfilled with
// metadata fetched by the background enrichment job below.
// Pure read of the enrichment store; the enrichment write
@ -351,7 +348,7 @@ class SubscriptionFeedViewModel : ViewModel() {
* complete in ~2s. Skipped per-item when FeedEnrichment already
* has a fresh hit (TTL controlled by Settings.cacheTtl).
*
* Runs on viewModelScope (round-67 audit HIGH-2): outliving the VM
* Runs on viewModelScope: outliving the VM
* would mean a destroyed _ui can still receive a stale emit (and
* mergeFromCache reads a now-cleared channelCache). The next
* VM instance does its own enrichment on next refresh; nothing
@ -375,10 +372,9 @@ class SubscriptionFeedViewModel : ViewModel() {
// strawcore.stream_info which expects a
// canonical YT URL. A poisoned cached
// item.url shouldn't be able to reach the
// extractor either. Round-69 audit
// family — uniffi.strawcore.* sites that
// take a user-influenced URL all get the
// gate.
// extractor either. All uniffi.strawcore.*
// sites that take a user-influenced URL get
// the same gate.
if (!com.sulkta.straw.util.isAllowedYtUrl(item.url)) return@withPermit
if (FeedEnrichment.get().get(videoId) != null) return@withPermit
val md = runCatchingCancellable {
@ -398,14 +394,13 @@ class SubscriptionFeedViewModel : ViewModel() {
// Compute the merge off-Main — flatMap + per-item regex
// + sort over up to 500 items is too much for the UI
// thread. Then hop to Main only for the StateFlow emit.
// Round-67 audit HIGH-1.
//
// Re-read subs at the terminal step (round-68 audit
// HIGH-6): the snapshot captured at refresh-end may
// Re-read subs at the terminal step.
// : the snapshot captured at refresh-end may
// include channels the user has since unsubscribed from
// in the ~2s enrich window. Intersect so a freshly-
// unsubscribed channel doesn't briefly re-appear in the
// feed after the enrich emit. Round-69 audit MED-4:
// feed after the enrich emit.:
// hoist the snapshot-URL set once instead of rebuilding
// it per filter iteration.
val snapshotUrls = channelsSnapshot.mapTo(HashSet()) { it.url }
@ -414,7 +409,7 @@ class SubscriptionFeedViewModel : ViewModel() {
val merged = withContext(Dispatchers.Default) {
mergeFromCache(mergeChannels)
}
// Honor cancellation post-merge — round-68 audit HIGH-2.
// Honor cancellation post-merge
coroutineContext.ensureActive()
_ui.update { it.copy(items = merged) }
}
@ -444,21 +439,20 @@ class SubscriptionFeedViewModel : ViewModel() {
* Clear in-memory cache. Called from Settings when the user flips
* off the local-cache toggle disk wipe via FeedCacheStore.clear()
* was already there, but the VM kept its in-memory mirror so items
* stayed visible until process death. vc=35 audit MED-C13.
* stayed visible until process death. audit MED-C13.
*/
fun clearInMemoryCache() {
// Cancel any in-flight refresh — without this, fetchChannelInto
// coroutines mid-execution would re-populate the cache after
// the clear. Round-3 audit function MED-3. Also cancel any
// the clear. Also cancel any
// enrichment fan-out (lives on globalScope, NOT viewModelScope)
// — otherwise a still-running enrichment would write to
// FeedEnrichment + then push a merged emit reading the empty
// channelCache. Round-67 audit HIGH-3.
// channelCache.
inFlight?.cancel()
enrichJob?.cancel()
channelCache.clear()
// Use _ui.update for atomicity vs concurrent refresh writes
// (round-3 audit HIGH-4).
_ui.update { it.copy(items = emptyList(), lastFetchedAt = 0L) }
}
}

View file

@ -75,10 +75,10 @@ fun MinibarOverlay(
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
// vc=35 audit MED-Q11: if Background-button took the user
// audit MED-Q11: if Background-button took the user
// to Home and the foreground audio fails, the only Player
// surface still listening is this minibar.
// vc=36 audit MED-3 + Q11: also stop the controller so a
// + Q11: also stop the controller so a
// future tap doesn't seek into the dead state, AND clear
// NowPlaying so the minibar hides itself. (PlayerScreen
// and VideoDetailScreen's listeners also clear NowPlaying

View file

@ -50,7 +50,7 @@ object NowPlaying {
* the winning caller's playback is already in flight.
*
* Uses MutableStateFlow.compareAndSet for the race-free transition.
* vc=35 audit HIGH-C6 the previous "check NowPlaying then
* audit HIGH-C6 the previous "check NowPlaying then
* direct assign" sequence had a window where both checks could
* pass before either write happened. The non-CAS `set()` setter
* that lived alongside this method was dropped in round-5 (no
@ -64,7 +64,7 @@ object NowPlaying {
// player, but if it brought richer metadata (full
// title vs the search-result truncation, fresh
// thumbnail, updated SponsorBlock segments) refresh
// those fields. vc=36 round-2 CVE MED-1.
// those fields.
if (cur != item) _current.compareAndSet(cur, item)
return false
}

View file

@ -139,7 +139,7 @@ class PlaybackService : MediaSessionService() {
// (with original streamUrl, uploader, thumbnail, SB segments)
// and push it into NowPlaying so the minibar + SponsorBlock
// skip-loop reflect the new track. claim() handles concurrent
// setPlayingFrom races — see vc=35 audit HIGH-C6.
// setPlayingFrom races — see audit HIGH-C6.
//
// SponsorBlock for queued items: when a queued item's segments
// are empty (which they always are — enqueueNext/Last doesn't
@ -176,7 +176,6 @@ class PlaybackService : MediaSessionService() {
// stuck on the previous video forever (would freeze
// VideoDetail's controllerOnThisVideo guard at false
// and lock the inline player into thumbnail+spinner).
// vc=62 audit BUG-5.
val uri = item.localConfiguration?.uri?.toString() ?: return
val fallback = NowPlayingItem(
streamUrl = uri,
@ -232,7 +231,6 @@ class PlaybackService : MediaSessionService() {
* currentPosition until prepare finishes and the new timeline
* lands without the gate we'd record A's tail position under
* B's videoId and auto-resume the user mid-A on next open.
* vc=62 audit BUG-4.
*/
private fun captureResumePosition(player: Player) {
val state = player.playbackState
@ -274,7 +272,7 @@ class PlaybackService : MediaSessionService() {
} ?: return@runCatchingCancellable
// Final allowlist gate before we hit strawcore with a
// URL whose origin was the extractor. Same defense as
// VideoDetailViewModel.load. Round-69 audit HIGH-2 /
// VideoDetailViewModel.load. /
// HIGH-3 family — every uniffi.strawcore.* site that
// takes a user-influenced URL needs this gate.
if (!isAllowedYtUrl(candidateUrl)) return@runCatchingCancellable
@ -319,8 +317,7 @@ class PlaybackService : MediaSessionService() {
if (uploaderUrl.isNullOrBlank()) return null
// uploaderUrl came from the extractor and flows
// through NowPlaying without revalidation. Same
// gate as the inline channelInfo path. Round-69
// audit HIGH-2.
// gate as the inline channelInfo path.
if (!isAllowedYtUrl(uploaderUrl)) return null
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
ch.videos

View file

@ -131,12 +131,11 @@ fun PlayerScreen(
// HttpDataSource exceptions embed the full request URI
// (with signature= / pot= / cpn=) in the .message
// string — visible in the on-screen error banner and
// a screenshot away from being shared. vc=36 audit
// CVE HIGH-1.
// a screenshot away from being shared.
val raw = error.message ?: "(no message)"
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
// Also clear NowPlaying so the minibar doesn't keep
// claiming a dead session is loaded. vc=36 audit MED-3.
// claiming a dead session is loaded.
NowPlaying.clear()
}
}
@ -374,7 +373,7 @@ fun SponsorBlockSkipLoop() {
val skipped = remember(cur.streamUrl) { mutableSetOf<String>() }
// Rate-limit the skip Toast — back-to-back segments in
// sponsor-dense videos used to queue 20+ Toasts that paint over
// the screen for 40s after the actual seek (vc=34 audit HIGH-B7).
// the screen for 40s after the actual seek ( audit HIGH-B7).
var lastToastAt by remember(cur.streamUrl) { mutableStateOf(0L) }
LaunchedEffect(cur.streamUrl, controller) {
while (true) {
@ -383,8 +382,7 @@ fun SponsorBlockSkipLoop() {
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
// Skip the position read + segment scan when not actively
// playing — on a paused-overnight session the prior shape
// hit the binder every 150ms for hours. Round-5 audit
// MED-2.
// hit the binder every 150ms for hours.
if (!controller.isPlaying) {
delay(1000)
continue
@ -421,6 +419,6 @@ private fun pickActiveSegment(
val interval = s.endSec - s.startSec > 0.1
// Drop the prior -0.05 exclusion — combined with the loop's
// 150ms polling cadence, short SB segments could fall in the
// gap and silently skip the skip. Round-6 audit MED-4.
// gap and silently skip the skip.
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec
}

View file

@ -100,7 +100,7 @@ fun Player.setPlayingFrom(
// Atomic claim BEFORE any controller mutation. If a concurrent
// caller already set this URL (inline player + fullscreen Player
// racing each other on the same transition), we bail before
// double-priming the player. vc=35 audit HIGH-C6.
// double-priming the player. audit HIGH-C6.
val claimed = NowPlaying.claim(nowPlayingItem)
if (!claimed) return
// Replace the queue when starting fresh — Queue mirrors the
@ -109,7 +109,7 @@ fun Player.setPlayingFrom(
// synced.
Queue.setAll(nowPlayingItem)
// Apply the user's max-resolution cap to DASH/HLS adaptive
// streams. Round-7 audit MED-3 — the cap previously only
// streams. — the cap previously only
// affected the videoOnly/combined picker; DASH manifests
// bypassed it because Media3 picked variants freely. setMaxVideoSize
// tells the ABR algorithm to never pick anything taller than
@ -123,7 +123,7 @@ fun Player.setPlayingFrom(
// or to the credits.
//
// Clamp the resume position against the RECORDED duration with a
// safety margin. vc=62 audit BUG-1: YouTube can replace a video
// safety margin.: YouTube can replace a video
// at the same videoId with a shorter cut (live→VOD trim, premiere
// edit, channel replace) — without the clamp, setMediaItem seeks
// past the new end, ExoPlayer fires onPlayerError, the screen

View file

@ -56,11 +56,11 @@ fun BoxScope.ThumbnailProgressOverlay(videoId: String?) {
// Plain collectAsState — collectAsStateWithLifecycle adds a
// DisposableEffect for lifecycle observation per call site, which
// adds up across 30 visible LazyColumn rows and contributes to
// scroll jank (vc=67). The Lifecycle pause optimization doesn't
// scroll jank The Lifecycle pause optimization doesn't
// matter for a foreground feed that's only collected while the
// composable is on screen anyway.
//
// Round-67 audit MED-2: derivedStateOf isolates each row's
// derivedStateOf isolates each row's
// dependency to ONLY its own videoId's entry. Without this, the
// 5s captureResumePosition tick re-emits the entire positions
// map → every visible thumbnail recomposes. With it, only rows

View file

@ -105,9 +105,8 @@ fun VideoActionsSheet(
return
}
// The action-sheet bypasses VideoDetailViewModel.load's
// allowlist gate (round-4 audit HIGH-4) — a long-press on a
// allowlist gate — a long-press on a
// poisoned related-card otherwise hits strawcore directly.
// Round-69 audit HIGH-3.
if (!com.sulkta.straw.util.isAllowedYtUrl(target.streamUrl)) {
Toast.makeText(context, "unsupported URL", Toast.LENGTH_SHORT).show()
onDismiss()

View file

@ -85,7 +85,7 @@ fun SearchScreen(
when {
// Loading WITH cached results: thin progress bar above the
// list, results stay visible. vc=34 audit B-1 — the prior
// list, results stay visible. audit B-1 — the prior
// order short-circuited to a centered spinner and hid the
// cached preview the VM was trying to show.
state.loading && state.results.isEmpty() -> Box(
@ -250,7 +250,7 @@ private fun ResultRow(
// Drop the duration here — VideoThumbnail's badge already
// renders it on the bottom-right of the thumbnail. Add the
// upload date instead so search results read like YT's
// own format. vc=65 — caught with the
// own format. caught with the
// channel-page + related-row consistency pass.
val meta = buildString {
if (item.viewCount > 0) append(formatViews(item.viewCount))

View file

@ -56,12 +56,12 @@ class SearchViewModel : ViewModel() {
* In-memory snapshot of the disk corpus (saved search results +
* subs feed cache) for reactive filtering. Hydrated on
* Dispatchers.IO once at VM construction and refreshed after a
* successful submit. vc=34 audit CRIT-C1 the previous
* successful submit.-C1 the previous
* implementation hit SharedPreferences + JSON-decoded ~225 KB on
* every keystroke, blocking the main thread.
*
* Plain @Volatile not StateFlow because nothing observes it
* (vc=36 audit LOW-R14 the StateFlow synchronization buys
* ( the StateFlow synchronization buys
* nothing here).
*/
@Volatile
@ -76,7 +76,7 @@ class SearchViewModel : ViewModel() {
* snapshot. Called at construction and from Settings when the
* cache toggle flips ON (so a re-enable picks up freshly-seeded
* entries from a subsequent submit/refresh without waiting for
* process death). vc=36 audit B2/Q10.
* process death). audit B2/Q10.
*/
fun rebuildPool() {
viewModelScope.launch {
@ -94,11 +94,11 @@ class SearchViewModel : ViewModel() {
}.distinctBy { it.url }
// Track the active submit so a fresh tap of Search cancels the
// previous network call rather than racing it. Round-4 audit
// HIGH-2: `_ui.value = _ui.value.copy()` patterns + concurrent
// previous network call rather than racing it.
// `_ui.value = _ui.value.copy()` patterns + concurrent
// submits were both lost-write hazards.
//
// Fence by Job identity, not query string. Round-6 audit HIGH-1:
// Fence by Job identity, not query string.:
// `onQueryChange` mutates _ui.value.query for reactive filtering
// WITHOUT cancelling inFlight, so a string-equality fence treats
// a still-valid result as stale just because the user kept typing
@ -110,7 +110,7 @@ class SearchViewModel : ViewModel() {
// Clear any prior error state when the user resumes typing —
// a failed submit's banner used to persist into the next
// reactive preview, looking like the new query had failed.
// vc=36 audit Q3.
// audit Q3.
_ui.update { it.copy(query = q, error = null) }
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
val matches = reactiveFilter(q.trim())
@ -135,7 +135,7 @@ class SearchViewModel : ViewModel() {
if (q.isEmpty()) return
// Cache hit on submit: show immediately, kick off refresh
// behind it. vc=36 audit B3 — the previous shape called
// behind it. audit B3 — the previous shape called
// `SearchCache.get().load()` on the main thread, doing the
// exact ~150 KB JSON decode the reactive-filter fix was
// supposed to eliminate. Now uses the StateFlow snapshot.
@ -145,7 +145,7 @@ class SearchViewModel : ViewModel() {
?.items
} else null
// Cancel any prior in-flight submit BEFORE writing the cached
// preview to the UI — round-7 audit MED-1: previously a fresh
// preview to the UI —: previously a fresh
// submit that hit the cache could be clobbered seconds later
// by the prior submit's late terminal write, because the
// prior coroutine had already advanced past its `ensureActive`
@ -194,7 +194,6 @@ class SearchViewModel : ViewModel() {
// me. Bare typing in the search bar (onQueryChange)
// doesn't cancel anything, so our results are still
// valid even if `_ui.value.query` moved on.
// Round-6 audit HIGH-1.
ensureActive()
_ui.update {
it.copy(
@ -243,7 +242,7 @@ class SearchViewModel : ViewModel() {
private fun reactiveFilter(q: String): List<StreamItem> {
// contains(ignoreCase=true) on the raw fields avoids the
// 3N+ String allocations per keystroke that `.lowercase()`
// copy-and-compare produced. Round-3 audit MED-5.
// copy-and-compare produced.
return pool.asSequence()
.filter { item ->
item.title.contains(q, ignoreCase = true)

View file

@ -525,7 +525,7 @@ fun SettingsScreen() {
// Cache re-enabled: trigger a real refresh
// so the feed repopulates without waiting
// for the user to navigate away and back.
// vc=36 audit B2.
// audit B2.
feedVm.refresh()
searchVm.rebuildPool()
}

View file

@ -34,7 +34,7 @@ private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo"
* Only accept file names that look like a plain APK basename. The index
* controls a string we substitute into an `ACTION_VIEW` intent; without
* sanitization a hostile or compromised index could ship `..//host/x.apk`
* or worse. Round-67 audit HIGH-5.
* or worse.
*/
private val APK_NAME_RE = Regex("""^/[A-Za-z0-9._-]+\.apk$""")
@ -43,7 +43,6 @@ private val APK_NAME_RE = Regex("""^/[A-Za-z0-9._-]+\.apk$""")
* digits; ten million is a horizon we won't hit organically but blocks
* a hostile index from latching us to Long.MAX_VALUE and burying every
* legitimate update behind a "you're already up to date" check.
* Round-67 audit HIGH-5.
*/
private const val MAX_PLAUSIBLE_VC = 10_000_000L
@ -57,7 +56,7 @@ object AppUpdateClient {
/**
* Pin two Subject-Public-Key-Info SHA-256 hashes against
* fdroid.sulkta.com so an off-tree CA misissue can't ship the
* user an attacker-signed index. Round-67 audit HIGH-5.
* user an attacker-signed index.
*
* - sha256/8ofd... current leaf SPKI. Rotates every ~90 days
* with each Let's Encrypt renewal; an app update before the
@ -101,7 +100,7 @@ object AppUpdateClient {
.maxByOrNull { it.manifest.versionCode }
?: return@runCatchingCancellable null
// Reject implausible versionCodes outright — see
// MAX_PLAUSIBLE_VC. Round-67 audit HIGH-5.
// MAX_PLAUSIBLE_VC.
if (best.manifest.versionCode <= 0 ||
best.manifest.versionCode > MAX_PLAUSIBLE_VC) {
strawLogW("StrawUpdate") {
@ -111,7 +110,6 @@ object AppUpdateClient {
}
// Strict APK-basename match before we hand this off to
// ACTION_VIEW. Anything else gets logged + dropped.
// Round-67 audit HIGH-5.
val fileName = best.file.name
if (!APK_NAME_RE.matches(fileName)) {
strawLogW("StrawUpdate") {

View file

@ -63,8 +63,8 @@ class UpdateCheckWorker(
runUpdateCheck(applicationContext)
// Always succeed — a failed check just retries on the next
// scheduled tick. Retry-with-backoff would burn battery for no
// gain (the index is sticky and fdroid.sulkta.com is on Cobb's
// own infra).
// gain (the index is sticky and fdroid.sulkta.com is on Sulkta
// infra, not a third-party rate limiter).
return Result.success()
}
}

View file

@ -36,8 +36,7 @@ object UpdateScheduler {
}
// WorkManager floors periodic intervals at 15 minutes.
// coerceAtLeast(15) future-proofs against a smaller enum case
// landing without anyone noticing the silent clamp. Round-67
// audit MED-4.
// landing without anyone noticing the silent clamp.
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(
interval.minutes.coerceAtLeast(15L),
TimeUnit.MINUTES,

View file

@ -29,7 +29,7 @@ const val STRAW_USER_AGENT: String =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"
// OkHttpClient is internally thread-safe; lazy(SYNCHRONIZED) builds
// exactly once across threads. Round-4 audit MED-6 — the prior
// exactly once across threads. — the prior
// synchronized(STRAW_USER_AGENT) locked an interned String literal
// shared with any other code in any library that happened to lock
// the same literal. Lazy-delegate avoids the global pool lock.

View file

@ -46,8 +46,7 @@ object SponsorBlockClient {
// string-concat built `?categories=["sponsor","selfpromo"]`
// with literal brackets/quotes — SB happens to accept it
// today, but the next time someone interpolates a non-enum
// string in there it becomes a URL-construction bug. Round-4
// audit MED-1 / LOW-2.
// string in there it becomes a URL-construction bug.
val url = "https://sponsor.ajay.app/api/skipSegments/$prefix".toHttpUrl()
.newBuilder()
.addQueryParameter("categories", buildJsonArray(categories))

View file

@ -6,8 +6,8 @@
* Pure functions on StreamItem so any list-rendering site can call them
* with one line at row-emit time.
*
* vc=56 ships only the shorts heuristic paid/age require strawcore
* flag plumbing landing in vc=57. The empty-stub fns are here so the
* ships only the shorts heuristic paid/age require strawcore
* flag plumbing landing previously. The empty-stub fns are here so the
* call sites we add now don't need to change when the flags arrive.
*/
@ -30,14 +30,14 @@ fun looksLikeShort(item: StreamItem): Boolean {
}
/**
* Placeholder until vc=57 adds an isPaid flag via strawcore-core.
* Placeholder until adds an isPaid flag via strawcore-core.
* Currently always false the hide-paid toggle still shows up in
* Settings so the user can pre-opt-in for when it lights up.
*/
fun looksLikePaid(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
/**
* Placeholder until vc=57 adds an isAgeRestricted flag. Same shape
* Placeholder until adds an isAgeRestricted flag. Same shape
* as looksLikePaid.
*/
fun looksLikeAgeRestricted(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false

View file

@ -4,7 +4,7 @@
*
* Coroutine-safe runCatching. Standard kotlin.runCatching catches
* Throwable including CancellationException, which is supposed to
* propagate so structured concurrency works. Round-5 audit HIGH-2
* propagate so structured concurrency works.
* surfaced the bug: a runCatching around a channelInfo() call
* inside a cancelled job swallowed the cancellation, the job
* carried on past the runCatching, hit its terminal write fence

View file

@ -43,7 +43,7 @@ object LogDump {
runCatching {
val pid = Process.myPid()
val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
// Write to cacheDir/logs/ — vc=37 round-3 audit CVE MED-5
// Write to cacheDir/logs/
// narrowed the FileProvider scope from the whole cacheDir
// to just this subdir, so dumps must land here.
val logsDir = File(context.cacheDir, "logs").apply { mkdirs() }
@ -105,7 +105,7 @@ object LogDump {
* `playbackError`) can scrub Media3's `PlaybackException.message`
* before rendering it to the user that string includes the full
* request URI for HttpDataSource exceptions, which would otherwise
* be a leak via screenshot. vc=36 audit CVE HIGH-1.
* be a leak via screenshot.
*/
fun scrubLine(line: String): String {
var s = line
@ -125,7 +125,6 @@ object LogDump {
// 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.
// vc=37 round-3 audit CVE-4.
private val SIGNED_PARAM_LONG_RE = Regex(
"""\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""",
RegexOption.IGNORE_CASE,

View file

@ -3,12 +3,11 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Shared YouTube-host allowlist. Originally lived inside
* SettingsImport for the import-time URL check; round-4 audit
* surfaced two more call sites VideoDetailViewModel's auto
* channelInfo(uploaderUrl) and recordWatch persistence that
* needed the same gate. Co-locating the set here so a future
* host (yewtu.be, hypothetical YT mirror) is one edit instead of
* three.
* SettingsImport for the import-time URL check, then two more call
* sites VideoDetailViewModel's auto channelInfo(uploaderUrl) and
* recordWatch persistence needed the same gate. Co-locating the
* set here so a future host (yewtu.be, hypothetical YT mirror) is
* one edit instead of three.
*/
package com.sulkta.straw.util
@ -22,8 +21,7 @@ private val ALLOWED_YT_HOSTS: Set<String> = setOf(
fun isAllowedYtUrl(url: String): Boolean {
val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return false
// Require an http/https scheme — `//host/...` (schemeless) and
// `mailto:host` both parse with a host attribute. Round-5 audit
// LOW-3.
// `mailto:host` both parse with a host attribute.
val scheme = uri.scheme?.lowercase() ?: return false
if (scheme != "https" && scheme != "http") return false
// Strip a single trailing dot (RFC FQDN form) before lookup.

View file

@ -4,7 +4,7 @@
Narrowed to a `logs/` subdir of cacheDir (was the entire
cacheDir) so a future bug that builds an attacker-influenced
FileProvider URI can't reach SettingsImport workdirs or
other cache state. vc=37 round-3 audit CVE MED-5. -->
other cache state. -->
<cache-path name="logs" path="logs/" />
<!-- Completed downloads. Downloader uses