Wire the new strawcore continuation fetchers through UniFFI and add
load-more-on-scroll to the Channel and Search screens — previously both
loaded only page 1 and stopped.
FFI (rust/strawcore): search() now returns Page{items, continuation};
channelInfo carries videos_continuation; new searchContinuation() and
channelVideosContinuation() suspend funs map the core ContinuationPage.
Channel + Search ViewModels: loadMore() fetches the next page, dedups by
url, advances the token, and stops when the token runs out or a page
yields zero net-new items (guards a looping continuation). Result-set
swaps (channel switch / new submit / cache preview) cancel the in-flight
page, and a token fence inside the state update prevents a stale page
being spliced into a replaced list. Screens add a near-end LazyColumn
trigger (rememberLazyListState + derivedStateOf) and a footer spinner.
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).
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.
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.