Commit graph

12275 commits

Author SHA1 Message Date
791975ca4a vc=86: audit-fix sprint (HIGH H2 + 5 MED/LOW from the 2026-06-21 audit)
All checks were successful
build-apk / build-and-publish (push) Successful in 7m22s
gitleaks / scan (push) Successful in 44s
- Audio-only toggle no longer drops the max-resolution cap: both the
  fullscreen button (PlayerScreen) and the detail Audio pill (VideoDetailBody)
  rebuilt TrackSelectionParameters from a fresh Builder, wiping the data-saver
  ceiling. Now buildUpon() the existing params so the cap survives. (H2)
- Subscriptions Refresh button no longer sticks at "..." forever on a warm
  restart within the cache TTL: refreshIfStale clears the initial loading
  seed when it decides nothing needs refreshing. (M2)
- Search + Channel result lists get a stable item key (video url) so paging /
  shorts-filtering stops re-binding rows to new data (re-triggered thumbnail
  loads, scroll shift). (M3, M4)
- IosSafeHttpDataSource: the unknown-length (LENGTH_UNSET) chunk path rolls
  forward to the next Range chunk at inner-EOF instead of re-reading the
  exhausted source forever (was truncating playback to the first chunk). (M5)
- strawcore channel_feed_rss propagates the real failure (network/HTTP/parse)
  instead of collapsing every error to an empty list, so a broken fetch is
  distinguishable from "no new videos" (subscription_feed keeps its per-channel
  tolerance for fan-out). (M6)
- Feed recency: a clock-skewed future upload emits "0 seconds ago" (parses to
  top) instead of "just now" (which Kotlin's recency parser couldn't read, so
  the item sank to the bottom). (L4)

Deferred to a follow-up: M1 (bg-refresh cache-key mismatch — needs a worker
redesign) + M7 (build config-cache wiring). Verified: cargo check/clippy +
full Android compileDebugKotlin green.
2026-06-21 13:37:51 -07:00
055c9c6d4f vc=85: image caching + SB/RYD clients -> Rust + crash/autoplay fixes
All checks were successful
build-apk / build-and-publish (push) Successful in 7m18s
gitleaks / scan (push) Successful in 43s
- Thumbnails + channel icons stay cached: pin an explicit 256MB Coil disk
  cache + sized memory cache via SingletonImageLoader.Factory. Coil's
  default disk cap is 2% of the device's free space, so on a storage-tight
  phone the subs feed (most image-heavy screen) thrashed it and
  re-downloaded thumbnails on every visit.
- SponsorBlock + Return-YouTube-Dislike clients moved Kotlin -> Rust
  (strawcore net.rs: fetchSponsorSegments / fetchRydVotes). SponsorBlock
  keeps its privacy-preserving SHA-256 hash-prefix lookup. Kotlin is now a
  thin shim mapping the FFI records onto the SbSegment/RydVotes domain
  types; behavior identical. Migration #2 of "all backend -> Rust".
- Fix crash: extract_channel_id sliced the channel URL by a length derived
  from a lowercased copy of itself; to_lowercase() can change byte length
  on non-ASCII, so a non-ASCII URL tail could panic across the FFI and
  abort the app on a feed refresh. Now matches the prefix case-insensitively
  against the original with length + char-boundary guards.
- Fix autoplay hijack: advancing to the next video resolves over ~500ms; if
  you manually start a different video meanwhile, autoplay would replace
  your choice with the stale next-up. Added a staleness fence.

Verified: cargo check/test/clippy on the wrapper, full Android
compileDebugKotlin green, adversarial FFI pre-push audit passed.
2026-06-21 12:59:04 -07:00
addd074f61 vc=84 — rename launcher to 'Straw' + move stream picker into Rust
All checks were successful
build-apk / build-and-publish (push) Successful in 7m17s
gitleaks / scan (push) Successful in 43s
Two changes:

1. Launcher name is now just 'Straw', not 'Straw debug' — past the
   debug-branding phase. Kept the .debug applicationId suffix (package
   stays com.sulkta.straw.debug) on purpose so fdroid updates install in
   place and the in-app auto-updater keeps working; dropping the suffix
   would change the package id and force a reinstall that wipes everyone's
   subs/history. That's a separate, deliberate release-track cutover.

2. Stream-selection logic moved out of Kotlin (resolveStreamPlayback) into
   the Rust strawcore wrapper as resolve_playback(StreamInfo, max_height)
   -> ResolvedStreams. The app keeps a thin shim that supplies the
   resolution cap (Settings.maxResolution) and attaches SponsorBlock
   segments. Byte-for-byte behavior parity with the old Kotlin picker:
   highest-bitrate stream at/under the cap, lowest-height fallback when
   nothing fits, first-element-wins on ties (matching Kotlin
   maxByOrNull/minByOrNull, not Rust's last-on-ties max_by_key), and
   isNotBlank() handling for the DASH/HLS URLs. First step of moving all
   backend logic to Rust; the picking lives at the FFI boundary because it
   depends on an app setting, keeping strawcore-core a pure extractor.

Wrapper cargo check + clippy clean (no new warnings); FFI surface adds
ResolvedStreams + resolvePlayback, bindings regen at build.
2026-06-21 11:41:50 -07:00
ea82ba765a vc=83 — fix: swapping videos from the minibar kept playing the old one
All checks were successful
build-apk / build-and-publish (push) Successful in 7m11s
gitleaks / scan (push) Successful in 40s
The inline player's auto-resolve→play LaunchedEffect read the shared
activity-scoped VideoDetailViewModel's `resolved` stream without checking
it belonged to the newly-opened video. Right after a swap (e.g. pick
another video from the browse screen while one sits in the minibar), the
VM still holds the previous video's resolved URLs for one composition
frame until vm.load() nulls them — so setPlayingFrom fired with
streamUrl=NEW but resolved=OLD: NowPlaying.claim won under the new url
(title/details/minibar flipped to NEW) while the controller kept
streaming OLD, and the correct re-fire with NEW's resolved was then
swallowed by the 'already playing this url' short-circuit.

Restore the loadedUrl fence (the same guard VideoDetailBody and every
ViewModel already use) that the vc=75 expandable-player rearchitect
dropped when the inline resolve→play wiring moved out of VideoDetailScreen.
2026-06-21 10:26:21 -07:00
2b3eb8bef4 vc=82 — subscription-feed enrichment via lightweight stream_metadata
All checks were successful
build-apk / build-and-publish (push) Successful in 7m1s
gitleaks / scan (push) Successful in 43s
enrich_feed_item now calls the new strawcore stream_metadata() path (Android
/player + videoDetails read only) instead of the full stream_info. The full
path ran the JS sig/nsig deobf, an extra WEB /player metadata round-trip, the
iOS client, and stream/manifest/caption extraction — then kept only view_count
+ duration_seconds. Those two come from the same videoDetails the lightweight
path reads (populate_microformat never touches them), so the values are
identical; the feed just stops paying for the discarded work — ~one heavy
round-trip dropped per enriched item per refresh.

FFI surface (enrichFeedItem -> EnrichedFeedMetadata) unchanged. Needs
strawcore 30f24d2 (pushed first; CI clones strawcore main).
2026-06-21 06:56:06 -07:00
1730ed3dc8 vc=81 — perf-audit app-side batch (search debounce + feed-merge memoize)
All checks were successful
build-apk / build-and-publish (push) Successful in 7m19s
gitleaks / scan (push) Successful in 44s
Search: the reactive cache-preview filter no longer runs on the main
thread on every keystroke. It walked the whole cached-results pool
(thousands of items on a heavy user) inline; now each keystroke
debounces ~150ms and the scan runs on Dispatchers.Default. A submit
cancels the pending preview so a late scan can't clobber live results.

Feed: mergeFromCache memoizes the relative-upload-date parse by string,
so the recency regex runs once per distinct "N days ago" value instead
of once per item (~3000 per merge on a 200-sub feed) — across
hydration, every refresh, and each background-enrichment emit.

No behavior change.
2026-06-21 06:26:40 -07:00
0e7f0b4781 vc=80 — strawcore extraction perf batch (borrow-not-clone + parallel channel browse)
All checks were successful
build-apk / build-and-publish (push) Successful in 7m3s
gitleaks / scan (push) Successful in 42s
Picks up strawcore 91d4824: streamingData + format objects borrowed
instead of deep-cloned per video open; channel Home/Videos tabs fetched
concurrently (one round-trip, not two); response bodies decoded in place
on the valid-UTF-8 path. No behavior change — internal allocation +
latency wins only.
2026-06-21 05:42:08 -07:00
96bad228ef Fix ExoPlayer wrong-thread crash + recordSearch off Main (perf-audit pass-2) — vc=79
All checks were successful
build-apk / build-and-publish (push) Successful in 7m11s
gitleaks / scan (push) Successful in 43s
- PlaybackService: the pauseOnHeadphoneDisconnect settings-watcher collector
  ran on globalScope (Dispatchers.IO) and called setHandleAudioBecomingNoisy
  on the ExoPlayer, which is thread-affine to the Main thread it was built on
  → latent IllegalStateException('Player accessed on the wrong thread') that
  fires every service start (the StateFlow emits on first subscription). Run
  the collector on Dispatchers.Main (mirrors resumePollJob).
- SearchViewModel: recordSearch (json-encode + SharedPrefs write) was on Main;
  wrap in withContext(Dispatchers.IO).

Both adversarially verified in the multi-agent perf audit (pass 2).
2026-06-21 05:04:39 -07:00
2defbd2925 Perf audit batch 1 (app-side): preserve res-cap on autoplay + detail LazyColumn — vc=78
All checks were successful
build-apk / build-and-publish (push) Successful in 7m36s
gitleaks / scan (push) Successful in 45s
From the multi-agent perf audit (adversarially verified), the two app-side wins:

- 1.1 Preserve the max-resolution cap on autoplay-next. The enter-video
  trackSelectionParameters reset built from a blank default, silently
  dropping the data-saver ceiling every URL change so autoplay streamed
  uncapped. Now: re-enable the video track via buildUpon + reassert
  applyMaxResolutionCap().
- 2.1 VideoDetailBody verticalScroll Column -> LazyColumn. Related +
  more-from-channel rows recycle and defer each AsyncImage decode + the
  two ThumbnailProgress flow collectors until scrolled into view (was ~40
  decodes + ~80 collectors eager on every open). Namespaced item keys
  (rel:/mfc:) so a url in both lists doesn't crash the list; kept take(20);
  dialogs hoisted out of the lazy content.
2026-06-21 04:44:58 -07:00
4b73616083 Expandable player: static poster during collapse/morph, live player only when expanded — vc=77
All checks were successful
build-apk / build-and-publish (push) Successful in 7m40s
gitleaks / scan (push) Successful in 38s
The remaining sluggishness was scaling a live-playing TextureView through
the morph's graphicsLayer every frame. Now the minibar + the whole
collapse/expand morph render the video's static poster (AsyncImage); the
live PlayerView only mounts once settled fully expanded. Audio is
unaffected — it's owned by the foreground service, never stops.
2026-06-20 17:02:03 -07:00
f6006047ff Expandable player: kill sluggish morph (cheaper compositing + snappy spring) — vc=76
All checks were successful
build-apk / build-and-publish (push) Successful in 7m8s
gitleaks / scan (push) Successful in 42s
- The detail body's alpha fade was rendering the whole scroll subtree to
  an offscreen buffer every frame (CompositingStrategy.Auto goes offscreen
  when alpha<1). Switch to ModulateAlpha — the body's rows don't overlap,
  so the per-draw-op fade is correct and skips the buffer. Main fix.
- Replace the 300ms FastOutSlowIn tween (slow ramp at both ends) with a
  no-bounce StiffnessMedium spring — distance-adaptive, reads as snappy.
2026-06-20 13:41:46 -07:00
e357861fb1 ci: put java on PATH for the apksigner verify step
All checks were successful
build-apk / build-and-publish (push) Successful in 7m2s
gitleaks / scan (push) Successful in 39s
Gradle build is green (uses JAVA_HOME directly), but the signer-verify
step calls apksigner — a shell wrapper that needs 'java' on PATH. The
straw-build image sets JAVA_HOME without adding its bin to PATH for run
steps, so apksigner died with 'exec: java: not found'. Export it.
2026-06-20 13:20:34 -07:00
5368a593a1 ci: run straw build steps with bash (dash has no 'set -o pipefail')
Some checks failed
build-apk / build-and-publish (push) Failing after 6m43s
gitleaks / scan (push) Successful in 45s
The runner's default shell for run: steps is dash, which errors
'Illegal option -o pipefail' the moment a step runs 'set -euo pipefail'
(the clone step, plus the pre-existing Verify + publish steps). Set
defaults.run.shell: bash for the job; the straw-build image ships bash.
2026-06-20 13:13:02 -07:00
4705fb5e4f ci: fix straw build workflow — plain git clone (no node) + dynamic apksigner
Some checks failed
build-apk / build-and-publish (push) Failing after 2s
gitleaks / scan (push) Successful in 40s
The build-and-publish job runs in the straw-build container, which ships
the Android + Rust toolchain but NOT node. actions/checkout@v4 is a Node
action, so it died with 'exec: "node": not found' before any source was
checked out — every build run since the workflow landed was red for this,
not the registry-pull theory.

- Replace both actions/checkout@v4 steps with a plain 'git clone' (git is
  in the image, both repos are public). Also sidesteps the runner's flaky
  data.forgejo.org action fetch. strawcore stays a sibling of straw for
  the rust/strawcore path dependency.
- Pick apksigner from whatever build-tools the image actually ships (36),
  not the hardcoded 34.0.0 that doesn't exist in it.

Build + publish prereqs verified present: docker CLI in image, runner
docker_host=automount + --group-add, and the STRAW_SIGNING_KEYSTORE_B64 /
STRAW_FDROID_RACKHAM_KEY secrets are set.
2026-06-20 13:11:22 -07:00
7b28d94189 Expandable player morph: one container, video page ⇄ minibar (vc=75)
Some checks failed
build-apk / build-and-publish (push) Failing after 45s
gitleaks / scan (push) Successful in 37s
Replace the separate Screen.VideoDetail page + MinibarOverlay with one
ExpandablePlayer container that morphs continuously between the full
video page and the bottom minibar, in both directions. The old flow just
made the minibar appear/vanish; this is a true shared-element transition.

- One fraction (0=minibar, 1=full page) drives a graphicsLayer
  scale+translate on a single mounted TextureView PlayerView. The
  transform runs in the render phase (reads the Animatable inside the
  layer block) so the morph is smooth without recomposing the detail
  body, and the same video surface stays live across the whole range.
- 100dp collapsed player is 16:9, same as expanded, so the morph is a
  pure uniform scale (no aspect distortion).
- Opening a video sets OpenVideo + expands instead of pushing a screen;
  the browse screen stays underneath so collapsing returns you there.
- New OpenVideo singleton (open video) distinct from NowPlaying (playing
  video); the two are kept in sync while collapsed so autoplay-next
  doesn't leave the open page stale.
- VideoDetailBody extracted from VideoDetailScreen; the inline player
  surface + resolve/play wiring became InlinePlayerSurface inside
  ExpandablePlayer. VideoDetailScreen + MinibarOverlay deleted.
- Back: fullscreen pops, then expanded collapses, then browse stack.
- Unchanged: shared controller, NowPlaying, setPlayingFrom, SponsorBlock,
  autoplay-next, PiP, background audio, and true-fullscreen Player (⛶).
2026-06-20 12:17:41 -07:00
e2723adc71 Minibar swipe-up-to-restore + vc=74 (0.1.0-CH)
Some checks failed
build-apk / build-and-publish (push) Failing after 53s
gitleaks / scan (push) Successful in 1m4s
Item 1 (partial): the minibar was tap-to-expand only — added an upward-drag gesture that expands back to the full player when released past a small threshold (Cobb: couldn't swipe the minibar back up). The continuous collapse-into-the-bar shared-element animation is deferred — needs on-device iteration. vc=74 ships items 2-5 fully + this.
2026-06-20 09:57:59 -07:00
22f4682608 Fix autoplay-next: play the next video instead of silently queuing it (vc=74 item 5)
At STATE_ENDED the finished video is still loaded (mediaItemCount==1), so enqueueLast appended the autoplay candidate at index 1 and the ENDED player never advanced to it — autoplay did nothing. Switch tryAutoplay to setPlayingFrom, which replaces + prepares + plays the next video. The old comment's 'queue is empty' assumption was the root error.
2026-06-20 09:56:08 -07:00
dd2345b1c8 Watched-status: persistent red bar + our play count (vc=74 items 2,3)
Item 2: thumbnail progress bar now stays full on watched videos — falls back to watch-history when the live resume point is gone (was vanishing on finished videos). A live resume entry still wins for mid-watch progress. Item 3: HistoryStore tracks a per-video playCount (increments each watch, carried forward atomically in recordWatch; defaults 1 for pre-vc74 entries). VideoDetail shows 'Watched N times' under the view count.
2026-06-20 09:53:51 -07:00
6f95b6fa3d Persist hide-watched toggle across restart (vc=74 item 4)
hide-watched was session-only remember state in StrawHome — reset to OFF on every cold start. Back it with SettingsStore (SharedPreferences), mirroring hideShorts: new hideWatched StateFlow + setHideWatched(). StrawHome reads/writes Settings.
2026-06-20 07:57:51 -07:00
af3c39a662 Strip NewPipe: remove legacy :app + unused :desktopApp/:shared modules
Some checks failed
build-apk / build-and-publish (push) Failing after 44s
gitleaks / scan (push) Successful in 42s
Straw runs on the strawcore Rust pipeline and ships only :strawApp — it is not NewPipe and uses none of their app code. Removes the 12M org.schabi.newpipe :app module (the fork base) and the unused NewPipe-origin KMP :desktopApp/:shared scaffold. settings.gradle now includes only :strawApp; also drops the NewPipe SPDX header + the NewPipeExtractor includeBuild stub. This also kills the recurring config-time git failure from app/build.gradle.kts.
2026-06-20 07:19:33 -07:00
b58804e101 VideoDetail vc=73: smooth swipe-dismiss + collapsible Details + clean action bar
Inline player → TextureView (XML surface_type) so the swipe-down-to-minimize drag follows the Compose graphicsLayer transform instead of the SurfaceView lagging behind (the stutter). Description folded into a collapsible Details section, collapsed by default, above recommendations. Action buttons restyled into one horizontally-scrollable row of uniform tonal icon pills; dropped the redundant Play button (inline player + fullscreen pill cover it).
2026-06-20 07:07:43 -07:00
5e89056f62 ci: Forgejo build workflow — per-repo straw-build image, gated auto-publish
Some checks failed
build-apk / build-and-publish (push) Failing after 1m5s
gitleaks / scan (push) Successful in 1m0s
Build the Straw APK in CI from a dedicated, ephemeral build container
(git.sulkta.com/sulkta-infra/straw-build — Android SDK/NDK + Rust +
cargo-ndk, see ci/Dockerfile) instead of the persistent crafting-table.
The runner spins the container up per job and tears it down after.

On push to main (after the build passes + the signer fingerprint is
verified against the canonical key) it publishes to fdroid.sulkta.com:
APK into the Lucy repo + index re-sign via the host docker socket, then
the signed repo streamed to Rackham web168 over a scoped forced-command
deploy key. Keystore + deploy key are Forgejo repo secrets.

Build steps run under `ionice -c3 nice` so they can't I/O-starve the live
DBs on Lucy.
2026-06-19 20:18:32 -07:00
845a8b9cc7 release: explicit signing config + vc 72 (0.1.0-CF)
All checks were successful
gitleaks / scan (push) Successful in 53s
Wire an env-driven signingConfig so CI/release builds reuse ONE keystore
instead of Gradle's per-machine auto-generated ~/.android/debug.keystore
— a fresh CI container would otherwise mint a different key and break
in-place fdroid updates for everyone. STRAW_KEYSTORE_FILE (+ _PASS /
_ALIAS / KEY_PASS) point at the canonical key; unset → default debug
signing so local dev needs no setup. The key is the same androiddebugkey
(SHA1 BB:9C:A9:6B…) that signed vc 15→71, now also vaulted.

vc 71 → 72 for the channel/search infinite-scroll pagination fix.
2026-06-19 19:38:48 -07:00
4dfb2e1450 straw: infinite-scroll pagination for channel + search
All checks were successful
gitleaks / scan (push) Successful in 41s
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.
2026-06-19 18:21:42 -07:00
6f2ae831cc ci: broaden gitleaks allowlist — catch all variable-name patterns. Refs #300
All checks were successful
gitleaks / scan (push) Successful in 35s
2026-05-28 12:19:24 -07:00
05521b487d ci: gitleaks allowlist — InnerTube public API key + SharedPreferences key constants. Refs #300
Some checks failed
gitleaks / scan (push) Failing after 34s
2026-05-28 12:16:20 -07:00
beb994b6e2 ci: add gitleaks workflow (Sulkta canonical)
Some checks failed
gitleaks / scan (push) Failing after 33s
2026-05-27 22:14:58 -07:00
42cb945654 Public-flip audit: scrub audit-ticket prefixes + LAN refs + tighten README
URLs → git.sulkta.com. Audit-ticket prefixes (SPEC §N, audit Track X, vc=N
audit-fix, FIX (audit ...), PORT DEVIATION) stripped from comments — technical
reasoning retained. Crafting-table LAN refs softened to 'Sulkta build host'.
README sheds marketing scaffolding + stale status tables.
2026-05-27 13:29:53 -07:00
5a757bea23 vc=71: embed strawcore-core cleanup + DoS fixes
Picks up two strawcore-core commits with no wrapper-side code change:
  * 7d2e4c5 — drop unused Phase-1 scaffolding (page, metainfo, service)
  * bfd06d1 — cap attribution_link recursion + Downloader body size

Both are surfaced in the .so embedded in this APK; vc bump is the
mechanism to get them to fdroid clients already on vc=70.
2026-05-26 22:04:50 -07:00
d73a4b53aa vc=70: audit-fix sprint round 3 (wrapper)
Round-3 audit (on vc=69) caught three real HIGHs plus three worth-fixing
MEDs. All in the same family: 'isAllowedYtUrl gate missing at consumer
side.'

HIGH-1 — ChannelScreen ignored the round-2 loadedUrl gate
  Round 2 added loadedUrl to ChannelUiState but ChannelScreen never
  read it. Channel A → back → Channel B showed A's data for one frame
  before vm.load(B) cleared it. Same shape as the VideoDetailScreen
  fix; matching gate added.

HIGH-2 — PlaybackService.tryAutoplay missing allowlist
  Both the SameChannel path (channelInfo(uploaderUrl)) and the
  candidate-resolve path (streamInfo(candidateUrl)) hit strawcore
  with extractor-derived URLs. Gate added on both call sites, plus
  the picker's intermediate uploaderUrl check.

HIGH-3 — VideoActionsSheet.enqueue missing allowlist
  Long-press on a poisoned related-card / channel video → 'Add to
  queue' invoked uniffi.strawcore.streamInfo on the raw target.url.
  Bail with Toast before launching the resolve coroutine.

MED-1 — FeedRefreshWorker doesn't retry on RequiresLogin
  reCAPTCHA challenges clear minutes-to-hours later. Treating them
  as permanent ate a full refresh cycle. Catch
  StrawcoreException.RequiresLogin → Result.retry().

MED-3 — VideoDetail.uploaderUrl persisted raw extractor value
  Round-2 added safeFresh for the avatar but the uploaderUrl saved
  to VideoDetail.detail was still info.uploaderUrl. NowPlaying →
  PlaybackService picked up the raw value. Validate once and persist
  the SAFE value so the whole downstream chain inherits it.

MED-4 — enrich-job filter rebuilt Set per iteration
  .filter { it.url in channelsSnapshot.map { c -> c.url }.toSet() }
  was O(N²). Hoist via mapTo(HashSet()) once.

Bonus sweep — gated two more uniffi.strawcore.* sites the round-3
agent's category prediction caught:
  * SubscriptionFeedViewModel.enrichVisibleItems → enrichFeedItem
    now skips items failing isAllowedYtUrl.
  * PlaybackService autoplay candidate-resolve already covered
    under HIGH-2 above.
2026-05-26 21:55:29 -07:00
23fb6f52b0 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
5f2ba264b0 vc=68 fixup: enable reqwest 'stream' feature for bytes_stream 2026-05-26 20:56:24 -07:00
c960a1f424 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
796244e065 vc=67: fix subs feed scroll jank
LazyColumn items() now keyed by url so pagination doesn't re-key every
row from scratch when visibleCount jumps. The displayed page slice is
remembered so SubsPane doesn't reallocate the take() ArrayList on every
recomposition. ThumbnailProgressOverlay switched from
collectAsStateWithLifecycle to plain collectAsState — the lifecycle
wrapper added a DisposableEffect per call site, which adds up across
the ~30 visible rows and was contributing to scroll hitch.
2026-05-26 15:32:47 -07:00
dd151e322d vc=66: hybrid feed backfill — RSS-fast + streamInfo-complete
Cobb 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
7156208c3c vc=65: metadata consistency across ChannelScreen + SearchScreen rows
vc=64 fixed RelatedRow's empty-metadata bug. ChannelVideoRow and
ResultRow had the same shape problem AND duplicated duration:

- ChannelVideoRow: showed 'N views · 0:42' which doubled with
  the VideoThumbnail's bottom-right duration badge. Stripped
  the duration text, added uploadDateRelative. Now reads
  'N views · 2 days ago' matching YT's channel page format.
- ResultRow: same duplicate-duration. Same fix. Search results
  now show 'N views · 2 days ago' under the uploader line.

All four video-row composables (FeedRow, RelatedRow,
ChannelVideoRow, ResultRow) now use the same leading-separator
buildString pattern: 'piece [· piece]*' that gracefully composes
whatever fields are populated. No more empty metadata lines, no
more duplicate duration.
2026-05-26 13:09:23 -07:00
944fbd4335 vc=64: UX polish — chip wrap + RelatedRow metadata
Two issues Cobb caught on the vc=63 walkthrough:

(1) Subscription chip names wrapped to two lines mid-word at 80dp
chip width: 'NoCopyrightS / ounds', 'DEFCONConfe / rence',
'Practica / Engineer...'. Switched to maxLines=1 + ellipsis +
center-align. 'NoCopyrigh…' reads cleaner than the broken wrap.

(2) Related + More-from-channel rows showed an empty metadata
line under the title because the buildString started with
item.uploader (empty for channelInfo-sourced rows — channel
pages omit the uploader name from each card since it's
implicit). Switched to a leading-separator pattern that
gracefully composes whatever pieces are populated:
'uploader · views · date', 'views · date', 'date', etc., and
hides the line entirely when nothing's available. Date was also
never rendered before — channelInfo gives it but RelatedRow
ignored it. Now visible everywhere.
2026-05-26 13:06:57 -07:00
7bd2740055 vc=63: fix stale-state nav-bug (new page shows, old video plays)
Cobb reproduced 2026-05-26: clicking video B from detail A's related
section opens detail B (title, description correct), minibar/media
notification show A's title, and AUDIO plays A. The bug everyone
was hitting as 'video bugs are back'.

Root cause — VideoDetailViewModel is activity-scoped, so navigating
A→B shows ONE composition frame with the previous video's state
before vm.load(B)'s reset propagates. During that frame:

1. VideoDetailScreen body runs with streamUrl=B but
   state.detail=A and state.resolved=A's playback URLs (stale).
2. InlinePlayer is called with title=A, streamUrl=B, resolved=A's.
3. Its LaunchedEffect launches a coroutine. Body is synchronous
   (no suspend), runs to completion before cancellation can
   interrupt.
4. setPlayingFrom(streamUrl=B, resolved=A's URLs) fires. claim()
   succeeds → NowPlaying = {streamUrl=B, title=A's title}.
   setMediaItem with A's playback URIs → player loads + plays A.
5. State reset propagates. InlinePlayer disposes.
6. After vm.load completes with B's data, InlinePlayer recomposes
   with B's resolved. Its NEW LaunchedEffect fires. The check
   'NowPlaying.streamUrl == streamUrl' returns true (because step 4
   already stamped streamUrl=B). RETURN EARLY. setPlayingFrom(B)
   NEVER fires with the correct B data.

Fix — add a loadedUrl field to VideoDetailUiState that tracks
which streamUrl the current detail/resolved actually belong to.
Gate VideoDetailScreen's player composition on
state.loadedUrl == streamUrl, so the stale-state frame can't fire
setPlayingFrom with mismatched data.

vm.load sets loadedUrl in the initial reset AND the success/error
updates — every state transition carries the URL that owns it.
2026-05-26 12:47:31 -07:00
6775f8252f vc=62: audit-fix sprint on playback regressions
Opus max-effort audit on vc=53-vc=61 diff caught four interlocked
playback bugs. Cobb's 'video bugs are back' likely lived in the
intersection of #1+#2 — stale auto-resume seek + no recovery path.

BUG-1: setPlayingFrom clamps auto-resume against entry.durationMs.
YouTube can replace a video at the same videoId with a shorter cut
(live->VOD trim, premiere edit). Without the clamp, setMediaItem
seeks past the new end, ExoPlayer fires onPlayerError, NowPlaying
clears, surface locks at thumbnail+spinner. Clamp at lookup uses
the recorded duration with a 5s safety margin; falls back to 0
when out of range.

BUG-2: InlinePlayer adds a Retry button on the playback-error
branch. Tapping it nulls playbackError + bumps a retryVersion that
re-keys the setPlayingFrom LaunchedEffect. Previously the screen
locked into the error message forever (no UI affordance to
re-attempt; LaunchedEffect's keys never changed). Bonus protection:
the manual retry path avoids the infinite-error-loop risk a
NowPlaying-keyed auto-retry would have created.

BUG-4: captureResumePosition now gates strictly on STATE_READY.
STATE_BUFFERING during a fresh setMediaItem reports the PREVIOUS
item's position via currentPosition — the 5s poll was happily
writing A's tail position under B's videoId in that window. Next
auto-resume would drop the user mid-A on a fresh open of B.

BUG-5: onMediaItemTransition falls back to MediaItem.mediaMetadata
when Queue.at(idx) is null. Without the fallback, a Queue/controller
desync would leave NowPlaying stuck on the previous item forever,
freezing controllerOnThisVideo at false and locking the inline
player into thumbnail+spinner on the next screen.
2026-05-26 12:31:27 -07:00
6cc789a8a0 vc=61: fix subs feed sort + date display
Cobb 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
26c9483b94 vc=60: storage usage readouts in cache settings
Each store + Coil image cache shows its actual on-disk byte count
next to its cap chip-row. Closes the loop on vc=59's cache controls
— users can see what each cap is doing instead of guessing.

- StorageUsage.sharedPrefBytes — reads dataDir/shared_prefs/X.xml
  length directly. Cheap, advisory; not authoritative on
  Android's internal SP layout but close enough to be useful.
- StorageUsage.coilDiskCacheBytes — pulls
  SingletonImageLoader.get().diskCache?.size, returns 0 if Coil
  hasn't lazily initialized yet.
- StorageUsage.format — KB/MB/GB renderer with 0 -> '—'.

Usage snapshot is captured once per Settings entry via remember{}
so File.length() doesn't refire on every recomposition.
2026-05-26 11:59:19 -07:00
aead95f1bc vc=59 cont: wire bg subs refresh + R8 keep + Settings UI
Bundling the background-refresh worker (originally planned as
vc=60) into the same release as cache controls — they're both
storage-and-refresh user-facing knobs, ships cleaner together.

- StrawApp.onCreate calls FeedRefreshScheduler.applyFromSettings
- R8 keep rule for FeedRefreshWorker (same reason as
  UpdateCheckWorker — WorkManager instantiates via reflection)
- Settings UI: 'Auto-refresh subs' toggle (default off) +
  interval chip-row (30min / 1h / 6h) shown when enabled. Lives
  in the existing Local cache section since it's the same
  storage-and-refresh theme.

Worker calls uniffi.strawcore.subscriptionFeed which fans out 50
parallel RSS fetches in Rust — 50 subs refreshes in ~1-2s in the
background. Writes per-channel into FeedCacheStore so next cold
open of Subs paints instantly.
2026-05-26 11:38:04 -07:00
c4bf7446c9 vc=59 fixup: restore MAX_*_HARD const declarations
Previous replace_all on 'MAX_WATCHES' over-matched 'MAX_WATCHES_HARD'
and produced 'maxWatches()_HARD' which Kotlin parses as garbage.
Same for MAX_SEARCHES_HARD, MAX_RESUMES_HARD, MAX_QUERIES_HARD.
Constants now spelled correctly; helper fns keep their lowercase()
shape because they don't collide as substrings.
2026-05-26 11:36:39 -07:00
2e75938f4e vc=59: per-store cache caps + TTL + Clear all caches
User-facing cache controls Cobb specifically asked for. Each
SharedPreferences-backed store now reads its cap from Settings
instead of a hardcoded constant:

- History watches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 50)
- History searches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 20)
- Resume positions: same options (was fixed 500)
- Search results cache: same options (was fixed 30 queries)

Each store also enforces a hard ceiling (100k for History +
Resume, 5k for SearchCache) so Unlimited doesn't OOM SP on a
hostile import.

New global Cache TTL: 1 day / 7 days / 30 days / 1 year /
Forever. Drops subs feed + search cache entries older than the
cutoff on every read. Defaults to 30 days.

Settings UI — new 'Cache & history limits' section inside the
existing Local cache block with one chip-row per cap + the TTL
chip-row + a 'Clear all caches' button that nukes FeedCache,
SearchCache, ResumePositions, History.watches, History.searches
on one tap.
2026-05-26 11:33:53 -07:00
7fff36c5e3 vc=58: parallel SponsorBlock + RYD fetch on video open
Sequential withContext blocks left the slower of the two requests
fully serialized behind the faster one — 200-500ms wasted per video
open. async{}+await in a coroutineScope runs both on Dispatchers.IO
concurrently. Saves the slower-task latency on every detail-screen
load.

Rust port was overscoped — the dominant cost is network latency,
not parse, so a UniFFI hop wouldn't help and the parallelization
fix is a 5-line Kotlin change. Updated the Rust port plan memo
accordingly.
2026-05-26 11:27:34 -07:00
8dec2f2621 vc=57: hide stale inline player frame during video switch
Cobb-reported 2026-05-26: tapping a related/search/subs video while
another is playing rendered the NEW detail page (title, description)
with the OLD video's last frame visible in the inline player slot.

Root cause — there's a window between streamInfo resolving for the
new URL and setPlayingFrom landing on the controller. PlayerView
bound to the controller renders the previous video's surface during
that window because the controller's MediaItem hasn't swapped yet.

Fix — observe NowPlaying.current and add a branch in InlinePlayer's
state-when that renders thumbnail + spinner when the controller is
still on a different streamUrl. Branch sits above the PlayerView
else-arm so the stale surface never gets attached. Flips to
PlayerView the moment NowPlaying.claim() lands the new URL.
2026-05-26 11:16:00 -07:00
50f4ce0a6c vc=56 fixup: hoist hideShorts collectAsState out of LazyListScope
LazyColumn's content lambda is LazyListScope, NOT @Composable, so
collectAsState() + remember() can't live inside the block body. Lift
both above the LazyColumn call (still inside the when{}'s else
branch). ChannelScreen — SubsPane and SearchScreen already had it
in the right scope.
2026-05-26 10:50:37 -07:00
12acf41c08 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
3a57696b46 vc=56 fixup: actually add reqwest+quick-xml+futures to Cargo.toml
Earlier Edit's old_string didn't match the file shape so the dep
additions never landed. Re-adding properly after android_logger.
2026-05-26 10:46:26 -07:00
341261584a 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