Commit graph

6 commits

Author SHA1 Message Date
Sulkta
1776234ed1 vc=69: audit-fix sprint round 2 (regressions on round 1)
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.
2026-05-26 21:31:07 -07:00
Sulkta
75b48dfefa vc=68: audit-fix sprint round 1 (11 HIGH + MED batch)
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
2026-05-26 20:53:25 -07:00
Sulkta
ac7fcd431a vc=66: hybrid feed backfill — RSS-fast + streamInfo-complete
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.
2026-05-26 13:40:26 -07:00
Sulkta
61580917f2 vc=61: fix subs feed sort + date display
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.
2026-05-26 12:24:33 -07:00
Sulkta
782f962c97 vc=56 fixup: bind QName temporary before passing to local_name
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.
2026-05-26 10:47:45 -07:00
Sulkta
06bd17d82e vc=56: subs feed via RSS (5-10x faster) + hide-shorts filter
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.
2026-05-26 10:44:06 -07:00