Round-2 audit caught four real regressions on round-1 fixes plus a
handful of MEDs. This sprint fixes them.
H1 — FeedRefreshWorker exception class
Round 1 wrapped subscriptionFeed in try/catch IOException, but
UniFFI generates StrawcoreException (kotlin.Exception, not
IOException). The retry path was dead code. Catch
StrawcoreException.Network instead — the variant our error.rs
maps NetworkError::Transport into.
H2 — enrichJob terminal emit cancellation race
withContext(Dispatchers.Default) { mergeFromCache(...) } has no
suspension points so a cancel arriving mid-merge isn't observed
until the next suspending call. Without a guard, the non-suspending
_ui.update lands AFTER clearInMemoryCache() and resurrects the
cleared items. Add coroutineContext.ensureActive() after each
withContext hop, before the emit. Applied on both the refresh
terminal emit and the enrich terminal emit.
H6 — enrichVisibleItems shows stale subscriptions
The channelsSnapshot captured at refresh-end is ~2s stale by the
time the enrich terminal emit runs. If the user unsubscribed from
X in that window, X's items still appear on the feed for one
frame. Re-read Subscriptions at the terminal step and intersect
with the snapshot.
R-H3 — extract_channel_id substring match
Round 1 used trimmed_lower.find(prefix) which matches ANY position.
evil.com/?redir=https://www.youtube.com/channel/UCxxx silently
rewrote to the embedded channel ID. strip_prefix() anchors at byte
0. ASCII-only prefix means byte indices align in trimmed_lower vs
trimmed.
R-H2 — String::from_utf8 silent-drop
YouTube ships mojibake titles in the wild. Strict from_utf8
returned None on any bad byte, dropping the entire channel from the
feed with only a quiet None. Switch to from_utf8_lossy — quick-xml
tolerates U+FFFD replacement chars and the per-entry skip-on-empty
handles broken entries.
R-H1 — read_capped_body per-chunk size sanity
HTTP allows arbitrarily large single chunks. Reject any chunk
exceeding the whole body cap before adding it to the buffer, so a
hostile server can't get us to allocate a hyper Bytes larger than
the cap.
M3 — Avatar URL validation
ch.avatar is extractor-emitted; a poisoned channel page could ship
data:image/svg+xml,<svg>...<script> or javascript: URLs. Validate
http(s):// scheme before persisting to Subscriptions and before
surfacing via VideoDetail.uploaderAvatar.
M4 — ChannelViewModel dual loadedUrl
Same shape VideoDetail's round-1 fix declared unsafe. Move
loadedUrl into ChannelUiState, drop the field, use _ui.value
snapshot at top of load() and _ui.value.loadedUrl for the fence.
Rejected-URL path also stamps loadedUrl so the gate is coherent.
Block B — enrichment lifecycle drift:
* SubscriptionFeedViewModel tracks enrichJob, cancelled in refresh
+ clearInMemoryCache so spam-refresh and cache-toggle no longer
leave a globalScope coroutine writing to a destroyed _ui
* Enrich now runs on viewModelScope, channels snapshotted at job
start so the terminal merge doesn't read a stale subs list
* mergeFromCache moved off Main on both the refresh path AND the
init-hydration path — 750-item flatMap+sort+regex no longer
blocks the UI thread
* VideoDetailViewModel dual loadedUrl bookkeeping collapsed to
the UiState field only; the rejected-URL path also stamps
loadedUrl so the gate reads coherently
Block A — auto-update authenticity:
* AppUpdateClient pins the fdroid.sulkta.com leaf SPKI + the
Let's Encrypt E7 intermediate via OkHttp CertificatePinner
* file.name accepted only when matching ^/[A-Za-z0-9._-]+\.apk$
* versionCode clamped to (0, 10_000_000] before we trust the
'update available' notification — a hostile index can no longer
pin us to MAX_VALUE
Block C — captureResumePosition perf:
* ResumePositionsStore.record short-circuits when the existing
entry matches position+duration so the 5s poll's
before !== next guard actually skips the SP write
* JSON encode + SP write off Main via globalScope IO
Block D — Rust feed.rs hardening:
* Shared reqwest Client via OnceLock — 50 channels no longer
pay 50 TLS handshakes
* Response body capped at 2 MiB via bytes_stream — adversarial
feeds can't OOM the JVM
* parse_rss returns partial results on quick-xml errors instead
of nuking everything already parsed
* extract_channel_id widened (m./www./http(s)?/trailing path)
and validates exact 24-char UC<22 base64-ish>
* Skip entries with empty title/published
* iso_to_relative future dates → 'just now' (clock skew
no longer pins items to top)
* civil_to_days year clamp 1970..=2200 before the i64 arithmetic
* Redirect chain capped at 3
* Dropped the broken lexicographic sort on upload_date_relative
* Cap parsed entries at 50 per channel
MED batch:
* ThumbnailProgressOverlay uses derivedStateOf so only rows
whose specific entry changed recompose on the 5s positions tick
* EnrichmentStore.put short-circuits on identical view+duration
so re-enrich within TTL doesn't write SP
* EnrichmentStore.load prunes TTL-expired entries on hydration
* FeedRefreshWorker distinguishes transient (Result.retry) from
parse (Result.success) failures
* WorkManager interval coerceAtLeast(15L) on both schedulers
a tester asked for views + durations back in the subs feed without
giving up the 5-10× RSS speedup vc=56 bought. Hybrid path:
1. Rust wrapper — new enrich_feed_item(video_url) ->
EnrichedFeedMetadata { view_count, duration_seconds }. Thin
wrapper around stream_info that discards the heavy play-URL
payload. Future opt: parse watch-page HTML JSON state directly
to skip JS deobf entirely. ~150 lines of pluck logic, punted.
2. EnrichmentStore — new SharedPreferences-lite store keyed by
videoId, value Enrichment(viewCount, durationSeconds,
fetchedAt). Bound to Settings.cacheTtl for staleness. Hard cap
5000 entries with oldest-eviction.
3. SubscriptionFeedViewModel — after the RSS refresh paints,
enrichVisibleItems() fans out enrichFeedItem for the first 30
items (skipping any already enriched fresh). Bounded at 8 wide
so we don't hammer YT; each call ~500ms full streamInfo so
30 items in ~2s. Runs on StrawApp.globalScope so a
refresh-cancel doesn't kill the in-flight enrichment.
mergeFromCache overlays the enrichment via .withEnrichment()
so RSS rows pick up viewCount + durationSeconds the moment
they land. The Enrichment store's StateFlow.value is read on
every merge call; the enrichment-complete handler triggers a
_ui.update that re-merges.
Net behavior: feed paints instantly from RSS (no view/duration),
~2s later the visible top-N populate with full metadata. Cached
forever (or until TTL/cap). Subsequent opens read straight from
EnrichmentStore.
StrawApp.onCreate inits the new store alongside the existing
SP-backed ones.
a tester caught the regression on vc=60: subs feed only showed LTT +
WTYP because vc=56's RSS path emitted raw ISO timestamps in
upload_date_relative, but Kotlin's recencyScore() parser only
understands 'N units ago' format. Every item tied at MIN_VALUE,
sort order went to whichever channel resolved first in the
50-concurrent fan-out — LTT + WTYP just happened to win the race.
Fix in feed.rs: parse the RFC3339 published timestamp, compute
delta from now, format as 'N second/minute/hour/day/week/month/year
ago'. Matches recencyScore's regex exactly. RSS still gives ISO;
we convert at the Rust boundary.
Standalone RFC3339 parser (no chrono dep) — Howard Hinnant's
civil-to-days algo, 30 lines, handles negative years correctly.
Display ALSO benefits — UI was showing the raw ISO string
('2026-05-19T13:00:31+00:00') in the channel row. Now reads
'7 days ago' like every other YT client.
quick-xml's BytesStart::name() returns a borrowed QName; calling
.as_ref() on it produced a &[u8] that outlived the QName by one
expression — borrowck E0716. Hoist the QName to a local so it
lives the full match arm.
Strawcore — new channel_feed_rss(channel_url) and subscription_feed
(bulk fan-out 50x via tokio buffer_unordered). Fetches the YouTube
Atom RSS at /feeds/videos.xml?channel_id=UCxxx. Each call is
~50-150ms vs ~500ms for the InnerTube channel_info page-scrape.
Deps added to strawcore wrapper Cargo.toml: reqwest (rustls-tls),
quick-xml, futures. reqwest dedupes against strawcore-core's
existing reqwest dep.
App — SubscriptionFeedViewModel.fetchChannelInto swapped to
channel_feed_rss. Parallelism cranked 12 -> 50 since each fetch is
lightweight now. perChannelMax dropped 30 -> 15 (the RSS upstream
cap is 15). RSS doesn't carry duration / viewCount / avatar — those
backfill on tap-through via the existing streamInfo path. Avatar
opportunistic-refresh dropped from this path (lazy-load on
ChannelScreen open is enough).
Hide-shorts content filter — new util/ContentFilter.kt with
looksLikeShort() (URL /shorts/ match OR title contains
'#shorts'/'#short'). Settings toggle defaults off. Filter applies
at row-emit in SubsPane, SearchScreen, ChannelScreen. Paid +
age-restricted stubs in place for vc=57 when strawcore-core gets
the flags.
Expected refresh time on 50 subs: ~30s sequential -> ~1s parallel-50
RSS.