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:
parent
5a757bea23
commit
42cb945654
51 changed files with 261 additions and 378 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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_\\-]+)",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* oldest→newest. Single SP write — vc=34 audit flagged the
|
||||
* oldest→newest. 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class SubscriptionsStore(context: Context) {
|
|||
|
||||
/**
|
||||
* Bulk-add. Single persist instead of N. Per-call `toggle()` was
|
||||
* O(N²) + N SP writes, which the vc=34 security audit flagged as
|
||||
* O(N²) + 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 ->
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue