Three round-3 Opus audits ran on vc=37. NO new CRITs (round-2 work
held) but real new HIGHs — several were vc=37 own-goals.
HIGH
R3-1 recordAllWatches dropped import on capacity=0. Old: when
watches store hit MAX_WATCHES (50), capacity=0, the whole
import was discarded silently. New: build fresh import list
capped at MAX_WATCHES, then combine + take(MAX_WATCHES) so
imports always land (truncating oldest current entries).
Also: skip SP write when next === before (no-op import on
already-saturated store no longer thrashes disk).
New recordAllSearches with same shape — round-3 CVE MED-6:
importHistory was per-row recordSearch.
R3-2 / CVE-2 SubscriptionsStore.addAll counter race. The vc=36
size-delta fix snapshot `cur = _subs.value` BEFORE
updateAndGet, so a concurrent toggle inflated `added`. New:
AtomicInteger reset at the start of each lambda re-run,
counted by checking each ref against the pre-image inside
the CAS. Exactly the additions THIS call made.
R3-3 refresh() empty-channels didn't cancel inFlight. Cancel
moved to the top of refresh() unconditionally so a refresh
on the prior sub set is killed before the empty branch
clears + wipes disk.
clearInMemoryCache also cancels inFlight — without it, a
cache-disable flip during a refresh could see fetchChannelInto
re-populate the just-cleared map.
R3-4 Non-atomic `_ui.value = it.copy(...)` at init hydrate path
and clearInMemoryCache. Replaced with `_ui.update {}` for
atomicity vs concurrent refresh writes. init's
lastFetchedAt write now uses maxOf so it never regresses
past a fresh refresh value.
CVE-1 state.error rendered raw UniFFI/Rust error strings to UI
— NetworkError::Recaptcha { url } embeds full signed
googlevideo URL. User screenshots a "reCAPTCHA at <URL>"
banner → leak. All four VMs (Channel/Detail/Feed/Search)
now scrub via LogDump.scrubLine before storing.
CVE-3 pruneCacheToSubs in init can clobber concurrent
fetchChannelInto writes. init's putAll → putIfAbsent so
a fresh entry from a parallel refresh isn't overwritten
with disk-stale data.
CVE-4 SIGNED_PARAM_RE over-redacted short tokens (`\bn=`
matched `n=42` counters from any wrapped lib). Split into
SIGNED_PARAM_LONG_RE (signature/sparams/lsig/cpn/expire/
pot/sig/key — match anywhere) and SIGNED_PARAM_SHORT_RE
(n/mn/ms/mo/pl/ip/ei — require `[?&]` immediately before).
Func-HIGH-1 refresh() swallowed CancellationException as a
user-visible error. Spam-tapping Refresh produced a
"refresh failed: StandaloneCoroutineCancelled" banner.
Re-throw CancellationException; catch only real errors.
MED
R3-5 reactiveFilter did N `.lowercase()` allocations per
keystroke. Switched to contains(ignoreCase = true) — zero
allocations.
CVE-MED-5 FileProvider cache-path was "." (whole cacheDir,
including SettingsImport workdirs). Narrowed to "logs/";
LogDump.capture now writes to cacheDir/logs/ to match.
CVE-MED-7 Downloader.Request.setTitle was the raw title
(bidi-override / control chars possible). Switched to
safeTitle.
CVE-MED-8 Rust hello_from_rust value-log scrubbed to name_len.
Func-LOW-4 recordAllWatches skip-write-on-no-change (`next !==
before`).
Deferred to a follow-up (not user-facing this ship):
R3-MED-6 — Settings setMaxResolution/setThemeMode/setCacheEnabled
not atomic via updateAndGet. Inconsistent with toggle()
but the Switch UI throttles enough that no real race.
R3-MED-8 — Minibar play-button reads live controller.isPlaying
instead of listener-tracked. One-frame oscillation on
super-fast double-tap.
R3-LOW — collectAsState vs collectAsStateWithLifecycle drift.
Func-LOW-6 — refreshIfStale isActive check is TOCTOU on a
non-existent multi-threaded call surface (LaunchedEffect
+ button are both Main).
Three Opus max-effort audits (CVE/security, code-health, function-
correctness) on the vc=34 surface returned a consolidated punch list
of 5 CRIT + 14 HIGH + ~10 MED. This commit lands all CRIT + HIGH +
the cheap MEDs in one cohesive pass.
CRIT — privacy + main-thread blocks
S1 IosSafeHttpDataSource was logging full pre-signed googlevideo
URLs (signature/sig/pot/expire/cpn) via raw android.util.Log.i
(no DEBUG gate). Then LogDump.capture would scrape its own PID's
logcat and ship it via the share sheet — a "report a bug to
Telegram" silently exfiltrated session credentials. Fixed by
switching all log calls to strawLogD/strawLogW (gated on
BuildConfig.DEBUG), dropping the full-URL log entirely, and
adding a regex scrub pass in LogDump for googlevideo URLs +
signed-param keys before the file hits disk.
S2 Downloader.enqueue handed signed googlevideo URLs to the system
DownloadManager, where they leak into DM's SQLite, logcat, the
system notification, and apps holding ACCESS_DOWNLOAD_MANAGER.
Set VISIBILITY_HIDDEN + setVisibleInDownloadsUi(false) so the
URL never surfaces in any external surface. Added the
DOWNLOAD_WITHOUT_NOTIFICATION permission DM requires.
S3 SettingsImport extracted the user's full newpipe.db (every sub,
watch, search) into cacheDir, deleted on finally. A force-kill
mid-import left the DB on disk indefinitely. Wrapped cleanup in
withContext(NonCancellable), switched workDir to createTempFile
(unguessable name), and added StrawApp.onCreate sweep of stale
newpipe-import-* dirs on every cold start.
C1 SearchViewModel.reactiveFilter ran a fresh SharedPreferences.
getString + Json.decodeFromString on FeedCache (~225 KB) AND
SearchCache (~150 KB) on EVERY keystroke. Hoisted into a
MutableStateFlow<List<StreamItem>> pool, built once on
Dispatchers.IO at VM init and refreshed after each successful
submit. Reactive filter now walks an in-memory list.
C2 SubscriptionFeedViewModel.init did the same FeedCache.load()
synchronously on the main thread at construction (first compose
pass blocked on the JSON decode). Moved into
viewModelScope.launch + withContext(Dispatchers.IO).
HIGH — function correctness + defense in depth
B1+B2 SearchScreen when-branch order: loading + error short-
circuited before the results branch, hiding the cached
preview the VM explicitly kept visible on cache-hit + on
network failure. Refactored to render the cached list under
a thin progress bar / error banner instead.
B3 Downloads "tap completed row" silently failed since minSdk 24
(FileUriExposedException on the file:// URI). Route through
FileProvider with new file_paths.xml entries for
Movies/audio + Movies/video.
B6 Manifest VIEW intent-filter was missing music.youtube.com and
youtube-nocookie.com hosts even though YT_HOSTS allowed them
— added both.
B7 SponsorBlockSkipLoop fired one Toast per skip with no rate
limit; sponsor-dense videos painted 20+ Toasts over 40s after
the seeks completed. 3s rate limit per cur.streamUrl.
Q8 resolvePlayback.pickVideo fallback used maxByOrNull when the
comment said "lowest available" — a 480p-capped user on a
1080p-only upload got 1080p (their data cap blown). Switched
to minByOrNull { height } when nothing fits the cap.
S1 SettingsImport extractZip had no size or entry-count caps —
zip-bomb could fill cacheDir. Added MAX_DB_BYTES (256 MB),
MAX_PREFS_BYTES (1 MB), MAX_ZIP_ENTRIES (64). copyBounded /
readBoundedBytes helpers replace the unbounded copyTo /
readBytes.
S2 Manifest: android:allowBackup=false +
android:dataExtractionRules=@xml/data_extraction_rules.xml +
android:fullBackupContent=false. Excludes root/file/database/
sharedpref/external from both cloud-backup and device-transfer
so the user's full search + watch history doesn't ride to
Google Drive.
S3 Dropped isLenient = true from every Json {} instance (7 sites
across data/ and net/). Lenient parser was buying nothing on
data we wrote ourselves and was a hardening gap on the third-
party SponsorBlock + RYD endpoints (community-run; malformed
payload could feed bad timestamps into the skip loop).
S4 SubscriptionsStore.addAll + HistoryStore.recordAllWatches bulk
methods, used by SettingsImport. Per-row toggle was O(N²) +
N SP writes; bulk path is O(N) + 1 write.
C3 SubsPane infinite-scroll LaunchedEffect keyed on
(displayed.size, hasMore) — both mutated BY the effect. The
collector cancelled itself mid-stream and dropped emissions,
producing "scroll to bottom, nothing more loads". Re-keyed on
listState + filteredCount; the collect lambda reads state
through the snapshotFlow producer to avoid stale captures.
C7 liveDrag + playbackSpeed: mutableFloatStateOf instead of
mutableStateOf — no Float boxing on the 100Hz drag callback.
C8 LogDump.capture is now suspend on Dispatchers.IO. The Settings
click handler launches into scope; button shows "Exporting…"
while in flight.
MED — cheap wins picked up in passing
Q9 reactiveFilter clears the cached preview when current query no
longer has any matches (was leaving stale results visible).
Q10 Hide-watched filter excludes blank video IDs from watchedIds
— a blank in the set used to match every malformed-URL feed
item and silently hide them.
Q13 PiP button in VideoDetail bails with a Toast on null
controller OR null resolved playback (was falling through to
enterPictureInPictureMode with no stream).
C17 SpeedPickerDialog row used fillMaxSize inside an AlertDialog;
only the first row got non-zero height. Fixed to fillMaxWidth.
Deferred to vc=36 follow-up (touch surface area we don't want to
churn in the same ship):
- C6 atomic setPlayingFrom guard in StrawMediaController
- S3 (full) — direct-streaming download replacing DownloadManager
- MED-C16 LazyColumn refactor in VideoDetailScreen
- MED-Q12 loadedUrl assignment ordering hardening
Five things land together — same surface, all the data/settings flow:
SettingsStore additions
themeMode: System / Light / Dark (default System)
cacheEnabled: Bool (default true). Single toggle gates both the
subs feed cache (FeedCacheStore) and the new search cache.
Theme override
StrawActivity reads Settings.themeMode and bypasses
isSystemInDarkTheme when Light or Dark is chosen. Changes apply
immediately via StateFlow recomposition — no restart needed.
SearchCacheStore (new)
SharedPreferences JSON of the last 30 searches with 20 items each
(~150 KB cap). Serialized via @Serializable StreamItem (already
serializable since vc=33).
Reactive search
SearchViewModel.onQueryChange now scans the merged corpus (saved
searches + subs feed cache, ~1500 records max) as the user types
past 2 chars. Matches on title or uploader (case-insensitive),
dedup by URL, cap 60. UI shows a "Cached results · …" hint when
the visible list is from cache so users know it's not the network
result yet.
submit() now paints any matching cached query immediately, then
kicks the network. Network results overwrite cache on success and
the cached preview survives network failures so offline users
still see something.
Cache opt-out
Settings switch "Enable local cache" wipes both stores when
flipped off. FeedCacheVM + SearchVM both short-circuit their
read/write paths when the flag is false.
Log dump
util/LogDump captures this PID's logcat (-d -v threadtime --pid=)
to cacheDir, returns a chooser Intent via FileProvider. New
Settings → "Export logs…" button. Toast surfaces the failure
reason if the dump command itself fails (sandbox-restricted
devices etc.).
FileProvider declared in manifest with cache-path "logs"; XML at
res/xml/file_paths.xml. Authority = ${applicationId}.fileprovider.