Three parallel Opus round-5 audits ran on vc=39. The big finds were
regressions on vc=39's own fixes — the retry-init wiring was incomplete,
the URL-fence pattern missed CancellationException swallowing, and the
SettingsStore idempotency branch was dead code. Real round-5 work.
HIGH
R5-1 Rust `ensure_initialized` was only called from `init_logging`
— extractor entry points never invoked it, so the 5s-backoff
retry from vc=39 was unreachable. A cold-boot init failure
still bricked extraction for the whole process. Now every
`search()` / `stream_info()` / `channel_info()` calls
ensure_initialized at entry; cheap when INITIALIZED is true.
R5-2 runtime.rs in-progress race: prior shape stamped
LAST_ATTEMPT_MS at the *start* of an attempt, then let
concurrent callers short-circuit via the backoff check
and proceed to call into the extractor before init
completed. New: lock FIRST (mutex IS the in-progress
queue), re-check INITIALIZED under lock, only THEN check
backoff (and only stamp it on failure).
R5-3 `runCatching` in VideoDetailViewModel + SubscriptionFeed
swallows CancellationException — when a job was cancelled
mid-suspend inside channelInfo/RYD/SB, the inner cancellation
was eaten, the lambda returned its default, the job carried
past the runCatching to its terminal write, and the URL fence
let it through because same-URL races can't be distinguished
by string equality. New util.runCatchingCancellable
re-throws CancellationException; all coroutine-body
runCatching sites in the affected VMs migrated.
R5-4 SearchViewModel.submit post-network fence only guarded
`_ui.update`. SearchCache.record + pool rebuild proceeded
for a cancelled query → ghost cache entries for queries
the user abandoned mid-stroke. Now: re-check the query
AFTER the IO suspend and before the cache write.
R5-5 ChannelViewModel.load / VideoDetailViewModel.load now gate
the extractor entry on isAllowedYtUrl(channelUrl/streamUrl).
Prior shape only gated recordWatch persistence — extractor
invocation for poisoned uploaderUrl still happened.
MED
R5-6 SettingsStore.set{MaxResolution,ThemeMode}: vc=39 used
`updateAndGet { r } == r` which is unconditionally true
(lambda ignores prior) — the in-memory equality check was
dead code. SP-side check still gated the disk write so the
feature worked, but the dead branch was a comment-vs-code
liar. Rewrote with explicit before-capture + equality
gate.
R5-7 SponsorBlockSkipLoop polled `controller.currentPosition`
every 150ms even when paused — paused-overnight playback
ate ~24k binder calls/hour. Now: when `!isPlaying`, sleep
1s and continue.
R5-8 StrawApp.appScope had no CoroutineExceptionHandler — an
uncaught throwable in a top-level launch could crash on
cold start even with SupervisorJob (top-level failure
still propagates to default handler). Added handler that
logs via strawLogW.
R5-9 YtUrl.isAllowedYtUrl now requires http/https scheme
(schemeless `//host/...` URLs no longer pass) and strips
a single trailing dot from host (RFC FQDN form). Defense
in depth.
LOW
R5-10 NowPlaying.set() removed — non-CAS setter footgun
alongside the race-free claim()/CAS path. No external
callers (grep clean). Doc-comment updated.
Deferred (later round / different scope):
- PlaylistsStore URL canonicalization (round-5 MED-1 — needs a
shared YT-id-extract util; not blocking).
- Release R8 + Nav rememberSaveable (still deferred).
- LazyColumn keys + collectAsStateWithLifecycle (cosmetic).
- SponsorBlockSkipLoop currentMediaItem binding (round-5
deferred).
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.
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).
CVE round-2 HIGH-2: android_logger is configured at info-level in
release builds, so log::info!('strawcore::search query={}', query)
emits the user's actual search query to logcat. LogDump.scrubLine's
regex only catches googlevideo URLs + signed params — bare search
text rides through into a Settings → Export Logs share-sheet
attachment intact. Same for channel_info / stream_info URLs.
Replaced the value-bearing logs with shape-only (query_len /
input_len). The shape is enough to debug 'why did the search
return empty?' without the privacy hit.
I confused StreamInfo (the big single-video struct, has
uploader_avatars: ImageSet) with StreamInfoItem (the card struct used
in search results / channel video lists / related streams — no
uploader_avatars field). cargoBuildHost caught it: E0609 no field
`uploader_avatars`.
Drop the field from SearchItem (and from the Kotlin construction
sites). For the subs feed and "more from this channel" we already
use the channel-level avatar from ChannelInfo.avatar, which is the
right granularity anyway (every video from one channel shares one
avatar). Per-card uploader avatars on search/related stay null until
strawcore-core extracts them on StreamInfoItem too.
Subscription feed is now actually a feed instead of a teaser.
Rust (strawcore wrapper)
Added upload_date_relative and uploader_avatar to SearchItem so
Kotlin can see both. strawcore-core already extracts upload_date
relative ("2 days ago") on every StreamInfoItem and uploader_avatars
on most — we were just throwing them away in from_core. Fixed.
StreamItem
uploadDateRelative + uploaderAvatar fields added. Every construction
site (search/channel/detail/feed) plumbs them through.
SubscriptionFeedViewModel
Per-channel cap 5 → 30. With 30 subs that's up to 900 items in
memory; ConcurrentHashMap entries are small enough.
Sort by parsed relative recency (RECENCY_RE on the "N <unit> ago"
string, signed seconds-ago, tied items break by viewCount).
Opportunistic avatar backfill: every successful channelInfo fetch
updates the stored ChannelRef.avatar via Subscriptions.updateAvatar
when strawcore returns a non-null avatar — fixes the "I just subbed
to a channel and the chip has no icon" case where the channel header
parser missed the avatar at subscribe time but the feed-fetch
layout returns one.
SubsPane (StrawHome)
Hide-watched FilterChip (session-sticky). Cross-references
History.watches by 11-char YT video ID; filters out anything you've
already watched. "All caught up — nothing unwatched" empty state.
Infinite scroll: PAGE_SIZE = 20. derivedStateOf-gated snapshotFlow
watches the LazyListState's lastVisibleItem index; when within 5
items of the bottom, bumps visibleCount by 20. "Loading more..."
spinner at the bottom while there's more to show.
Visible-count resets to PAGE_SIZE when the underlying list shrinks
(refresh dropped items, filter just engaged).
FeedRow now shows: uploader · views · "3 days ago".
SubChip
Lettered fallback when ch.avatar is null. PrimaryContainer-tinted
circle with the first letter — no more broken-image placeholder
while the feed-fetch backfills the real avatar.
SubscriptionsStore
updateAvatar(url, avatar) for the backfill path. Atomic via
updateAndGet, persists to SharedPreferences.
rquickjs-sys 0.11 ships pre-generated bindings only for x86_64 hosts;
Android targets need bindgen at build time. Direct-dep with the feature
flag so cargo's feature resolver lifts it to the transitive use through
strawcore-core → rquickjs → rquickjs-sys.
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.
Phase U (rustypipe Rust extractor) rolled back. Symptom: black screen
on play, root cause: rustypipe 0.11.4's JS deobfuscator can't parse
current YouTube player.js (YT changed the obfuscation pattern, no
upstream rustypipe release since June 2025). Switching clients
(Web → TV → Android/Ios) didn't help — the deobfuscator init fires
universally.
Kept in place for the future:
- rust/strawcore/ Cargo workspace + UniFFI scaffolding
- crafting-table runtime install (rustup + 4 Android targets +
cargo-ndk + NDK r27c)
- The U-2..U-5 commits in history (re-runnable when rustypipe is
fixed or we fork it).
Restored from commit 9550b207a (v0.1.0-T):
- NewPipe.init() in StrawApp.onCreate
- libs.newpipe.extractor + libs.squareup.okhttp deps
- NewPipeDownloader.kt + Thumbnails.kt
- ViewModels (Search/VideoDetail/Player/Channel/SubscriptionFeed) on
NewPipeExtractor calls
- VideoDetailScreen Download dialog using NewPipe's StreamInfo
Future-direction memo: openclaw-workspace/memory/project_rustypipe_fork.md
— fork plan + revival path for the Rust extractor when we're ready
to maintain it.
Verified working in the Android emulator: dQw4w9WgXcQ plays, ExoPlayer
reports state=PLAYING(3), position advancing, video surface rendering.
Black-screen-on-play bug: rustypipe's default player() uses YT's Web
client, which serves stream URLs that are session/UA-locked. ExoPlayer
fetching with a different UA gets a silent 403 from googlevideo and
renders a black surface.
Fix: pin stream_info() to player_from_clients(id, [Tv, Ios]) — the
TVHTML5 + iOS Innertube clients serve direct-play URLs that work in
any HTTP player. Same trick NewPipe uses. No Apple/iOS code involved
— it's just the API client identifier rustypipe sends to YT.
Also added a Player.Listener in PlayerScreen that Toasts any
PlaybackException (codeName + message) so future stream failures don't
look like silent black screens. Logs to logcat 'StrawPlayer' too.
channel_info(url) UniFFI suspend fn. ChannelViewModel +
SubscriptionFeedViewModel both swap. NewPipeExtractor (Java) is OUT —
zero org.schabi.newpipe classes in the APK now.
Cleanup:
- NewPipeDownloader.kt deleted (was the OkHttp adapter)
- Thumbnails.kt deleted (rustypipe returns full URLs)
- NewPipe.init() dropped from StrawApp.onCreate
- libs.newpipe.extractor removed from build.gradle.kts
- STRAW_USER_AGENT + strawHttpClient() now live in net/Http.kt
- RydClient + SponsorBlockClient + PlayerScreen + PlaybackService all
read from net/Http.kt instead of the extractor package
rustypipe API quirks beat:
- channel_videos(id) is the right method (channel() doesn't exist)
- ChannelInfo struct = basic metadata; Channel<T> wrapper carries
name/avatar/banner + .content is the paginator of videos
- description is String (not Option), subscriber_count is Option<u64>
End state: strawApp Kotlin is ~UI + thin glue to strawcore. The Rust
core handles search / streamInfo / channel / channel_videos via UniFFI
suspend fns. Tokio + reqwest + rustls + rquickjs all packed in
libstrawcore.so (~6MB per ABI). APK 40MB total.
stream_info(url) UniFFI suspend fn replaces NewPipeExtractor's
StreamInfo.getInfo() for both VideoDetailViewModel and PlayerViewModel.
One Rust round-trip drives the detail screen render AND the player's
resolve(). The VideoDetailUiState.info field cached on detail load is
reused by the Download dialog so we don't refetch.
Deferred to U-3.5:
- like_count (rustypipe's player() doesn't surface engagement data;
a separate query is needed)
- related (player() doesn't include 'up next'; comes from a separate
endpoint). Kotlin gets empty list for now — RelatedRow handles it.
Type quirks vs my initial guesses (caught by cargo check):
- details.duration is u32, not Option<u32>
- channel is split into channel_id + channel_name, not a struct
- like_count doesn't exist at this query depth
- VideoFormat::Webm (lowercase mb), VideoCodec::Avc1 (not H264)
- video_only is a separate vec (video_only_streams), not a bool flag