Three parallel Opus max-effort audits ran on vc=38. No new CRITs (the
LogDump + VM-error-scrub chain held), but real new HIGHs across VMs
that weren't touched in rounds 1-3 + the Rust runtime's brittle
one-shot init.
HIGH
R4-1 Rust runtime::ensure_initialized was one-shot via Once.
First-call failure (cold-boot in airplane mode, transient
DNS/SELinux denial on first TLS init) consumed the Once slot
and bricked the extractor for the rest of the process —
every subsequent search/streamInfo/channelInfo returned
DownloaderMissing forever. Replaced with AtomicBool + 5s
backoff retry; success closes the door, failure retries on
the next call.
R4-2 VideoDetailViewModel.load tracked no inFlight Job.
Activity-scoped VM is reused; tap video A → quickly tap a
related-video B → both loads race, slower-finisher wins.
A's resolved payload (different itags, different SB
segments, wrong title chip) could render on the B detail
page; recordWatch logged B while the player streamed A.
Now: inFlight?.cancel() at top, fenced terminal writes with
loadedUrl-stable guard. Same shape applied to
ChannelViewModel (had no in-flight tracking at all).
R4-3 `_ui.value = _ui.value.copy(...)` lost-write patterns
survived round-3's pass in SearchViewModel + VideoDetail +
Channel. Migrated all to `_ui.update {}` — same atomicity
regression class round 3 was supposed to close. Submit/load
terminal writes also now fence against late-arrivals.
R4-4 HistoryStore.recordAllWatches reported `size_after -
size_before` to SettingsImport — at a saturated store the
post-state size equals the pre-state size even when 20
fresh imports landed and 20 older entries got truncated.
User saw "0 watch history imported" when 30 actually
landed. Now: recordAllWatches/recordAllSearches return an
AtomicInteger-counted actual-fresh-added count from inside
the CAS lambda; SettingsImport plumbs through to the report.
R4-5 SubscriptionFeedViewModel.refresh() filtered to stale-only
— user-initiated tap of Refresh was a silent no-op when
every channel had been refreshed in the last 28min.
Split: refresh() forces fan-out across every sub;
refreshIfStale() keeps the TTL filter. Both share
refreshInternal(force: Bool).
R4-6 SettingsImport.importPlaylists called create() + addItem()
in a loop — both write SP, and addItem walks every playlist
linearly per insert. A NewPipe export with 100 playlists ×
100 items = ~10k SP commits + O(N²) work. New
PlaylistsStore.importPlaylist mints a single Playlist with
pre-attached items, one CAS, one SP write per playlist.
R4-7 VideoDetailViewModel auto-called channelInfo(uploaderUrl)
on every load — no allowlist gate. An extractor-emitted
non-YT uploaderUrl (poisoned related/moreFromChannel)
would have triggered an arbitrary-host network call.
R4-8 Similar shape: VideoDetailViewModel.recordWatch persisted
whatever URL was passed to load() — extractor-emitted non-YT
URLs would have survived in Recent Watches past process
death. Same import-time URL allowlist now gates both.
CVE-1 The reCAPTCHA error path embedded the full google.com/sorry/
URL into the user-visible banner. That URL carries
`continue=<full-signed-googlevideo-url>` — and LogDump's
scrub only matches googlevideo.com hosts. Now: strip the
`continue=` param in Rust before propagating; UI shows a
tappable challenge URL that still solves the rate-limit
when the user opens it.
MED
R4-9 SettingsStore.setMaxResolution/setThemeMode/setCacheEnabled
were not atomic vs toggle()'s updateAndGet pattern. Now
CAS-safe + idempotent (no SP write when the value is
already what's stored).
R4-10 SponsorBlockClient.fetch built the URL via string concat
with un-percent-encoded JSON-shaped categories list.
Switched to HttpUrl.Builder().addQueryParameter() — okhttp
does the right escaping. SB happens to accept the raw form
today; this guards future user-typed categories.
R4-11 strawHttpClient() synchronized on the interned
STRAW_USER_AGENT string literal — any unrelated code that
happened to lock the same literal could contend. Replaced
with lazy(SYNCHRONIZED) — same one-shot init, no shared
global lock.
R4-12 DownloadsScreen.queryDownloads ran on the main coroutine
every 1-5s. DownloadManager.query is a ContentResolver IPC
+ SQLite cursor walk; on devices with hundreds of historical
downloads it stuttered. withContext(Dispatchers.IO).
R4-13 Co-located the YT host allowlist (was inline in
SettingsImport) into util/YtUrl.kt — VideoDetailViewModel
now imports the same function. Future host changes are
one edit.
Deferred to round 2-5:
R4-MED — Nav.kt has no rememberSaveable / Parcelize on Screen
sealed types. Process-death loses entire back stack.
Needs Parcelize plugin add + listSaver — bigger refactor.
R4-HIGH — Release isMinifyEnabled = false / no R8. Needs
comprehensive keep-rules for UniFFI + kotlinx-serialization
before flipping safely. Holding for a dedicated round.
R4-MED — LazyColumn key= missing in 5 list sites; quick win
but cosmetic, won't slip into post-round-5 ship.
R4-MED — collectAsStateWithLifecycle bulk-replace.
R4-MED — SponsorBlock skip-loop should bind segments to
controller.currentMediaItem to avoid one-tick misapply on
track changes.
Replaces the rustypipe-backed extraction with calls into the new
NPE-port crate. The UniFFI surface Kotlin sees is unchanged:
suspend fun search(query: String): List<SearchItem>
suspend fun streamInfo(input: String): StreamInfo
suspend fun channelInfo(input: String): ChannelInfo
fun initLogging() // also wires the strawcore-core Downloader
fun helloFromRust(name: String): String
rust/strawcore/
* Cargo.toml — dropped rustypipe + rquickjs-sys direct dep;
added strawcore-core path dep (../../../strawcore)
* src/error.rs — From<strawcore_core::ExtractionError>, mapping
ContentUnavailable variants to typed
StrawcoreError cases (AgeRestricted, GeoRestricted,
Private, RequiresLogin) instead of bucketing all
to Extractor
* src/runtime.rs — Once-guarded ReqwestDownloader init via
NewPipe::init_full
* src/search.rs — search() spawn_blocks core search_extractor::search
against SearchFilter::Videos
* src/stream.rs — stream_info() resolves URL → video_id via
strawcore_core::linkhandler::stream, then
spawn_blocks core stream_extractor::stream_info,
then maps StreamInfo → wrapper DTOs (combined/
video_only/audio_only/dash/hls)
* src/channel.rs — channel_info() parses input via
strawcore_core::linkhandler::channel (handle /
custom-url / legacy-user resolution lives in
core), then spawn_blocks core channel::channel_info
Build verified: wrapper compiles linking strawcore-core, uniffi-bindgen
generates Kotlin bindings with the same suspend fun + data class
surface Kotlin already consumes. Android NDK cross-compile + APK + on-
device smoke pending (needs crafting-table container).
This commits onto rollback/vc18-back-to-NPE — the existing Kotlin code
still calls NewPipeExtractor directly. Switching the Kotlin side to
consume the rust wrapper is a separate cutover.