Commit graph

11 commits

Author SHA1 Message Date
2cfb26bbd3 vc=43: loop round 5/5 — final ship
Round-5 audit verdict: SHIP. Zero new CRIT/HIGH after the round-7
honesty check confirmed diminishing returns. Only follow-up was a
stale manifest comment pointing at YT_HOSTS (collapsed to util.YtUrl
in vc=42); rewritten to point at util/YtUrl.kt's ALLOWED_YT_HOSTS.

The 5-round audit loop (rounds 4-8 overall, after vc=38's prior
3 rounds): 18 HIGH + 28 MED + handfuls of LOW landed across vc=39
through vc=43. Most material fixes:
  * Rust runtime ensure_initialized wired into every extractor
    entry, mutex-first then try_lock (no UI freeze on slow init)
  * VideoDetail / Channel / Search VM in-flight cancel + fence
    pattern; runCatchingCancellable to defeat the runCatching-eats-
    cancellation hazard at every coroutine boundary
  * Allowlist gate on every extractor entry point (not just
    persistence) — util/YtUrl with scheme + trailing-dot defenses
  * Bulk-import write-storm collapse on every store + return-real-
    added-count so the import summary doesn't lie at saturation
  * SponsorBlock skip-loop pause-skip + 50ms-exclusion drop
  * Recapcha URL strip-continue-param in Rust before propagation
  * SettingsStore truly atomic+idempotent; PlaylistsStore bulk
  * Hostile-zip duplicate-entry rejection + playlist LIMITs
2026-05-25 15:44:18 -07:00
e76a325faa vc=35: audit-fix sprint — 5 CRIT + 14 HIGH + opportunistic MEDs
Three Opus max-effort audits (CVE/security, code-health, function-
correctness) on the vc=34 surface returned a consolidated punch list
of 5 CRIT + 14 HIGH + ~10 MED. This commit lands all CRIT + HIGH +
the cheap MEDs in one cohesive pass.

CRIT — privacy + main-thread blocks
  S1  IosSafeHttpDataSource was logging full pre-signed googlevideo
      URLs (signature/sig/pot/expire/cpn) via raw android.util.Log.i
      (no DEBUG gate). Then LogDump.capture would scrape its own PID's
      logcat and ship it via the share sheet — a "report a bug to
      Telegram" silently exfiltrated session credentials. Fixed by
      switching all log calls to strawLogD/strawLogW (gated on
      BuildConfig.DEBUG), dropping the full-URL log entirely, and
      adding a regex scrub pass in LogDump for googlevideo URLs +
      signed-param keys before the file hits disk.
  S2  Downloader.enqueue handed signed googlevideo URLs to the system
      DownloadManager, where they leak into DM's SQLite, logcat, the
      system notification, and apps holding ACCESS_DOWNLOAD_MANAGER.
      Set VISIBILITY_HIDDEN + setVisibleInDownloadsUi(false) so the
      URL never surfaces in any external surface. Added the
      DOWNLOAD_WITHOUT_NOTIFICATION permission DM requires.
  S3  SettingsImport extracted the user's full newpipe.db (every sub,
      watch, search) into cacheDir, deleted on finally. A force-kill
      mid-import left the DB on disk indefinitely. Wrapped cleanup in
      withContext(NonCancellable), switched workDir to createTempFile
      (unguessable name), and added StrawApp.onCreate sweep of stale
      newpipe-import-* dirs on every cold start.
  C1  SearchViewModel.reactiveFilter ran a fresh SharedPreferences.
      getString + Json.decodeFromString on FeedCache (~225 KB) AND
      SearchCache (~150 KB) on EVERY keystroke. Hoisted into a
      MutableStateFlow<List<StreamItem>> pool, built once on
      Dispatchers.IO at VM init and refreshed after each successful
      submit. Reactive filter now walks an in-memory list.
  C2  SubscriptionFeedViewModel.init did the same FeedCache.load()
      synchronously on the main thread at construction (first compose
      pass blocked on the JSON decode). Moved into
      viewModelScope.launch + withContext(Dispatchers.IO).

HIGH — function correctness + defense in depth
  B1+B2  SearchScreen when-branch order: loading + error short-
         circuited before the results branch, hiding the cached
         preview the VM explicitly kept visible on cache-hit + on
         network failure. Refactored to render the cached list under
         a thin progress bar / error banner instead.
  B3   Downloads "tap completed row" silently failed since minSdk 24
       (FileUriExposedException on the file:// URI). Route through
       FileProvider with new file_paths.xml entries for
       Movies/audio + Movies/video.
  B6   Manifest VIEW intent-filter was missing music.youtube.com and
       youtube-nocookie.com hosts even though YT_HOSTS allowed them
       — added both.
  B7   SponsorBlockSkipLoop fired one Toast per skip with no rate
       limit; sponsor-dense videos painted 20+ Toasts over 40s after
       the seeks completed. 3s rate limit per cur.streamUrl.
  Q8   resolvePlayback.pickVideo fallback used maxByOrNull when the
       comment said "lowest available" — a 480p-capped user on a
       1080p-only upload got 1080p (their data cap blown). Switched
       to minByOrNull { height } when nothing fits the cap.
  S1   SettingsImport extractZip had no size or entry-count caps —
       zip-bomb could fill cacheDir. Added MAX_DB_BYTES (256 MB),
       MAX_PREFS_BYTES (1 MB), MAX_ZIP_ENTRIES (64). copyBounded /
       readBoundedBytes helpers replace the unbounded copyTo /
       readBytes.
  S2   Manifest: android:allowBackup=false +
       android:dataExtractionRules=@xml/data_extraction_rules.xml +
       android:fullBackupContent=false. Excludes root/file/database/
       sharedpref/external from both cloud-backup and device-transfer
       so the user's full search + watch history doesn't ride to
       Google Drive.
  S3   Dropped isLenient = true from every Json {} instance (7 sites
       across data/ and net/). Lenient parser was buying nothing on
       data we wrote ourselves and was a hardening gap on the third-
       party SponsorBlock + RYD endpoints (community-run; malformed
       payload could feed bad timestamps into the skip loop).
  S4   SubscriptionsStore.addAll + HistoryStore.recordAllWatches bulk
       methods, used by SettingsImport. Per-row toggle was O(N²) +
       N SP writes; bulk path is O(N) + 1 write.
  C3   SubsPane infinite-scroll LaunchedEffect keyed on
       (displayed.size, hasMore) — both mutated BY the effect. The
       collector cancelled itself mid-stream and dropped emissions,
       producing "scroll to bottom, nothing more loads". Re-keyed on
       listState + filteredCount; the collect lambda reads state
       through the snapshotFlow producer to avoid stale captures.
  C7   liveDrag + playbackSpeed: mutableFloatStateOf instead of
       mutableStateOf — no Float boxing on the 100Hz drag callback.
  C8   LogDump.capture is now suspend on Dispatchers.IO. The Settings
       click handler launches into scope; button shows "Exporting…"
       while in flight.

MED — cheap wins picked up in passing
  Q9   reactiveFilter clears the cached preview when current query no
       longer has any matches (was leaving stale results visible).
  Q10  Hide-watched filter excludes blank video IDs from watchedIds
       — a blank in the set used to match every malformed-URL feed
       item and silently hide them.
  Q13  PiP button in VideoDetail bails with a Toast on null
       controller OR null resolved playback (was falling through to
       enterPictureInPictureMode with no stream).
  C17  SpeedPickerDialog row used fillMaxSize inside an AlertDialog;
       only the first row got non-zero height. Fixed to fillMaxWidth.

Deferred to vc=36 follow-up (touch surface area we don't want to
churn in the same ship):
  - C6 atomic setPlayingFrom guard in StrawMediaController
  - S3 (full) — direct-streaming download replacing DownloadManager
  - MED-C16 LazyColumn refactor in VideoDetailScreen
  - MED-Q12 loadedUrl assignment ordering hardening
2026-05-25 13:27:30 -07:00
a776fbf2e4 vc=34: settings batch — theme, cache toggle, log dump, reactive search
Five things land together — same surface, all the data/settings flow:

SettingsStore additions
  themeMode: System / Light / Dark (default System)
  cacheEnabled: Bool (default true). Single toggle gates both the
    subs feed cache (FeedCacheStore) and the new search cache.

Theme override
  StrawActivity reads Settings.themeMode and bypasses
  isSystemInDarkTheme when Light or Dark is chosen. Changes apply
  immediately via StateFlow recomposition — no restart needed.

SearchCacheStore (new)
  SharedPreferences JSON of the last 30 searches with 20 items each
  (~150 KB cap). Serialized via @Serializable StreamItem (already
  serializable since vc=33).

Reactive search
  SearchViewModel.onQueryChange now scans the merged corpus (saved
  searches + subs feed cache, ~1500 records max) as the user types
  past 2 chars. Matches on title or uploader (case-insensitive),
  dedup by URL, cap 60. UI shows a "Cached results · …" hint when
  the visible list is from cache so users know it's not the network
  result yet.
  submit() now paints any matching cached query immediately, then
  kicks the network. Network results overwrite cache on success and
  the cached preview survives network failures so offline users
  still see something.

Cache opt-out
  Settings switch "Enable local cache" wipes both stores when
  flipped off. FeedCacheVM + SearchVM both short-circuit their
  read/write paths when the flag is false.

Log dump
  util/LogDump captures this PID's logcat (-d -v threadtime --pid=)
  to cacheDir, returns a chooser Intent via FileProvider. New
  Settings → "Export logs…" button. Toast surfaces the failure
  reason if the dump command itself fails (sandbox-restricted
  devices etc.).
  FileProvider declared in manifest with cache-path "logs"; XML at
  res/xml/file_paths.xml. Authority = ${applicationId}.fileprovider.
2026-05-25 13:01:41 -07:00
2e339814fd vc=28: edge-to-edge player, nav-bar inset, video-track reset, app icon
Three layout/playback fixes on vc=26-27 feedback plus the long-deferred
app icon.

Layout — VideoDetailScreen
  Player surface now fills the screen width (no 16dp side gutters).
  Outer Column dropped its 16dp padding; the player Box hangs off the
  full width with no rounded corners — NewPipe/YouTube look. Everything
  below (title, chips, button row, description, related list) goes back
  into an inner Column with 16dp horizontal + 12dp vertical padding so
  the body still reads correctly.
  Bottom inset: Spacer(windowInsetsBottomHeight(WindowInsets.navigationBars))
  appended at the end of the scrollable column. Last related video can
  scroll up past the gesture pill / 3-button nav instead of being
  obscured by it. (Plain navigationBarsPadding would have pushed the
  whole surface up and left a dead band.)

Black-video fix
  vc=27's Background button disabled the video track on the controller
  via setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) and that override
  is sticky. Returning to a video left it audio-only with a black
  surface. Added a LaunchedEffect(controller, streamUrl) that resets
  TrackSelectionParameters to defaults on every entry into detail — if
  the user opened a video page, they want video. The audio-only
  fullscreen toggle and the Background button still set the override
  for the duration of that session; they just no longer leak.

App icon
  Replaced the Android default placeholder (sym_def_app_icon, which
  fdroid was failing to render anyway) with a proper adaptive icon:
    Background: #166534 deep green (sulkta.com brand)
    Foreground: tilted lime parallelogram + white play triangle
      (literal "straw" nod + video-app affordance)
  Adaptive XML in mipmap-anydpi-v26/. PNG fallbacks rendered via
  rsvg-convert at all five mipmap densities (mdpi/hdpi/xhdpi/xxhdpi/
  xxxhdpi) for pre-API-26 devices. Manifest now points at
  @mipmap/ic_launcher and @mipmap/ic_launcher_round.
2026-05-25 11:43:38 -07:00
9550b207ab v0.1.0-T (vc=7): bug fixes + Opus audit pass #2 + home redesign
User-visible:
- BUG: 'clicking a second item went back to the first video' — VideoDetailViewModel
  guard was short-circuiting on Activity-scoped ViewModel reuse. Now tracks
  loadedUrl and only skips when the requested URL matches.
- BUG: PiP / window mode now auto-enters on Home gesture (Android 12+ via
  setAutoEnterEnabled). Manual PiP button reports failure cause via Toast.
- HOME REDESIGN: replaced 3-tab bottom nav with hamburger ModalNavigationDrawer.
  Default view = sub feed. Drawer items = Subscriptions / History / Search /
  Settings. Top-left hamburger like normal Android apps.

Audit pass #2 (Opus max-effort) — CRIT + HIGH fixes shipped:
- CRIT-1: PlaybackService now calls startForeground() inside onStartCommand
  with a media-playback notification + channel. Pre-fix could throw
  ForegroundServiceDidNotStartInTimeException on Android 12+ and crash-kill.
- CRIT-2: AndroidManifest service exported=false. Previously any installed
  app could craft an Intent and drive playback from attacker URLs.
- HIGH-1: 🎧 background handoff stops the activity player before starting
  the service so we don't dual-host two ExoPlayers + MediaSessions.
- HIGH-2: onStartCommand returns START_NOT_STICKY and tears down on null
  intent; no more crash-restart-crash loop after OS kills.
- HIGH-3: stop service on STATE_ENDED / STATE_IDLE via Player.Listener.
  onTaskRemoved checks playbackState properly so we don't hold WAKE_LOCK
  forever after a video ends in background.
- HIGH-4: Downloader validates scheme=https + googlevideo/youtube host
  before handing the URL to DownloadManager.
- HIGH-5: filename sanitization extended to ASCII control chars, DEL,
  Unicode bidi-override block, leading-dot, trailing whitespace.
- HIGH-6: SubscriptionFeedViewModel cancels prior in-flight refresh,
  caps parallelism at 8 via Semaphore, applies 15s per-channel timeout.
- HIGH-7: sub feed error banner now shows above cached items when refresh
  fails (previously hidden, looked indistinguishable from success).
- HIGH-8: PlayerViewModel falls back to lowest-available stream when no
  stream is under the max-resolution ceiling (was: silent black screen).
- HIGH-9: network_security_config explicit cleartextTrafficPermitted='false'
  on the RYD domain-config block (doesn't inherit from base-config).
- MED-1: PlaybackService.onDestroy nulls field before releasing session to
  close a race with onGetSession during teardown.
- MED-6: Downloader catches enqueue exceptions, returns -1L, caller toasts
  'download refused (bad URL)' instead of crashing.

Deferred (audit said 'can wait'): MED-2..5, MED-7..11, HIGH-10 UX consistency.
2026-05-24 07:49:35 -07:00
Kayos
7894fe5a4d Straw phase M-2: MediaSession + audio focus + service skeleton
Wraps the existing Activity-owned ExoPlayer in a Media3 MediaSession. Side
effects:
- Lock-screen media controls (play/pause)
- System media notification with play/pause buttons
- Audio focus + ducking (other audio dims when straw plays)
- Bluetooth + headset hardware-button routing
- Plays nicely alongside other media apps' notifications

The ExoPlayer is also configured with AudioAttributes (USAGE_MEDIA +
CONTENT_TYPE_MOVIE) and handleAudioFocus=true so other apps can request
focus correctly (e.g., a phone call ducks/pauses straw).

Also scaffolded but NOT yet wired:
- PlaybackService extending MediaSessionService — the foundation for
  true background-after-Activity-kill audio. Manifest entry + deps
  added (media3-session 1.4.1, concurrent-futures-ktx 1.2.0). The
  Activity-to-Service migration via MediaController is M-3 work.
- POST_NOTIFICATIONS, FOREGROUND_SERVICE,
  FOREGROUND_SERVICE_MEDIA_PLAYBACK, WAKE_LOCK permissions in manifest.

For the user right now: lock the phone while a video plays — media
controls appear on the lock screen. Open another app — notification with
play/pause sits in the shade. Press a headset's play/pause — straw
responds. Pull the home screen — PiP keeps the video floating + audio
continues. Full screen-off-background-audio after killing the activity
arrives in M-3.
2026-05-23 20:46:47 -07:00
Kayos
1578de5dbb Straw phase M-1: tap-thumbnail-to-play + hi-res thumbs + Picture-in-Picture
Cobb's real-use feedback: "playing a video is a button under a very
pixelated thumbnail, no playing in window mode, ... where are all the
features." This is the visible-UX pass.

Thumbnails:
- New util/Thumbnails.kt#bestThumbnail picks the highest-res image from
  NewPipeExtractor's List<Image> by w*h pixel area. Was firstOrNull()
  which is the smallest in the sorted list. Applied across Search,
  Channel video rows, Channel avatar + banner, VideoDetail.

Tap-to-play:
- VideoDetail thumbnail is now wrapped in a clickable Box that fires
  onPlay(). Centered semi-transparent black circle with a white play
  triangle overlay so the affordance is obvious. The standalone "Play"
  button below stays for accessibility (tap target consistency).

Picture-in-Picture:
- Manifest: android:supportsPictureInPicture="true" on StrawActivity +
  added screenLayout|smallestScreenSize|screenSize to configChanges so
  rotation into PiP doesn't recreate.
- PlayerScreen: top-right floating button enters PiP with 16:9 aspect.
- Lifecycle ON_STOP observer now checks isInPictureInPictureMode and
  skips pausing when we're in PiP (was killing playback on PiP entry).

Phase M-2 next: MediaSession + background audio + foreground service
(the "play with screen off / lock screen controls" feature).
2026-05-23 20:40:53 -07:00
Kayos
2fd439cac8 Straw audit-fix sprint: CRIT-1 + HIGH-9 + targeted MED
Phase L. Triages findings from the Opus max-effort audit
(memory/2026-05-23-night3-straw later; agent report inline).

CRIT-1: ACTION_SEND URL extracted from arbitrary text/plain share now
  re-validated via URI host check before reaching NewPipeExtractor.
  Manifest scheme also validated for VIEW. Regex broadened to accept
  music.youtube.com + youtube-nocookie. Host set expanded.

HIGH: usesCleartextTraffic="true" removed from manifest (was redundant
  with network_security_config + misleading to reviewers).

HIGH: NewPipeDownloader hardens header copy — runCatching around addHeader
  to survive poisoned response headers with \r/\n. Explicit UA added AFTER
  upstream header copy so it wins. Switched to OkHttp 5 extension form
  for toRequestBody.

HIGH: Bounded response bodies via new util `cappedString(maxBytes)`. Caps
  at 8MiB (NewPipeExtractor), 1MiB (SponsorBlock), 256KiB (RYD). Defends
  against OOM via gigabyte response. Partial defense — chunked transfers
  without Content-Length still streamed up to cap.

HIGH: HistoryStore / SettingsStore / SubscriptionsStore moved from
  non-atomic `_flow.value = next` to `_flow.updateAndGet { ... }`. Closes
  the read-modify-write race that could drop concurrent writes.

HIGH: VideoDetailViewModel.load had a dead-code `if (...)` block with no
  body — falls through every call. Replaced with a real early-return on
  detail-already-loaded.

HIGH: ChannelViewModel picked `info.tabs.firstOrNull()` which is YouTube's
  curated "Home" tab. Switched to `firstOrNull { ChannelTabs.VIDEOS in
  contentFilters }` with fallback. Channel screen now shows actual videos.

HIGH: PlayerScreen SponsorBlock skip loop hardened:
  - dedup skipped UUIDs so re-listen doesn't fight the user
  - poll 250ms → 150ms reduces sponsor leak through buffering
  - `isPlaying` → `playbackState != IDLE/ENDED` so buffering doesn't miss
  - clamp seek away from duration boundary (prevents past-end jank)
  - filter POI-style point segments (start ≈ end)

MED: ExoPlayer pause on app background via Lifecycle.Event.ON_STOP
  observer. Was silently playing audio with no MediaSession when app
  was backgrounded.

MED: Log.d/Log.w gated behind BuildConfig.DEBUG via new strawLogD/strawLogW
  inline helpers in util/Log.kt. Lambda body skipped in release too.
  Log.i remains unguarded for user-quotable events. RydClient +
  SponsorBlockClient + PlayerScreen updated.

MED: Hardcoded version "v0.1.0" in StrawHome replaced with
  BuildConfig.VERSION_NAME. Now reads "v0.1.0-day1" matching ProjectConfig.

MED: Triplicated formatCount/formatViews/formatDuration extracted to
  util/Formatting.kt. Search/Channel/VideoDetail import the shared
  functions.

MED: SponsorBlockClient.buildJsonArray uses kotlinx-serialization Json
  encoder instead of hand-rolled string concat — defends against future
  user-typed category names breaking the URL.

MED: Description regex passes capped to 20k input chars before stripHtml
  on detail screen — defends against ANR on multi-MB descriptions.

Deferred to phase M (not in this sprint):
  - R8 + ProGuard rules for release builds (isMinifyEnabled=true)
  - ExoPlayer hoisting into PlayerViewModel (AndroidViewModel)
  - DI / Koin to replace `error("not initialized")` singleton accessors
  - String resources / i18n
  - rememberSaveable nav stack for process-death restore
  - onNewIntent override for in-running YouTube URL shares
  - certificate pinning on RYD endpoint
2026-05-23 20:23:34 -07:00
Kayos
f3b78b4530 Straw phase F: visible polish — RYD, HTML, intent filter, network sec
Four polish items, all visible:
1. Fixed RYD URL: was returnyoutubedislike.com (the website, 404s on /votes)
   → returnyoutubedislikeapi.com (the actual API).
2. Bundled Sectigo "Public Server Authentication CA DV R36" intermediate
   as an additional trust anchor (network_security_config.xml) for the
   .com web host — the API uses Google Trust Services already so this
   is defensive, in case we ever fetch the landing page.
3. Added intent-filter for YouTube URLs (VIEW + SEND) on StrawActivity.
   Single-task launchMode; pickYouTubeUrl() routes the initial Intent
   to Screen.VideoDetail(url) instead of Home.
4. stripHtml() utility removes <br>, </p>, and other tags from
   NewPipeExtractor's description. Chapter timestamps now render
   readably; raw HTML gone.

Also added Log.d to SponsorBlockClient and RydClient for visible
verification (StrawSb / StrawRyd tags). PlayerScreen now shows a
"SB: N segments" overlay chip in the top-left so the user can see
that SponsorBlock is armed.

Verified on Android 14 emulator with "Me at the zoo" intent:
- RYD: 👍 18.9M / 👎 424.2K renders
- Intent: direct URL → VideoDetail (no Home pass-through)
- HTML: chapter timestamps render clean
2026-05-23 19:40:08 -07:00
Kayos
496ed30bda Sulkta day-2: search → detail → player → SponsorBlock + RYD
Phase A: NewPipeExtractor + OkHttp Downloader wired in. Search bar +
LazyColumn results. Tap = navigate to detail.

Phase B: VideoDetail screen — StreamInfo metadata + Return YouTube
Dislike chips + description.

Phase C: Media3 ExoPlayer in Compose. Resolves StreamInfo to best
playable: DASH MPD → HLS → combined progressive → merged
videoOnly+audio.

Phase D: SponsorBlock SHA-256 prefix lookup. 250ms position-poll loop
inside PlayerScreen — exoPlayer.seekTo(segment.end) when entering
a sponsor segment. Toast on skip.

Phase E: Verified live on Android 14 emulator. linus tech tips search
returns real results with thumbnails; tapped result opens detail;
hit Play → video plays through ExoPlayer.

Architecture: everything in :strawApp for now (not pushed into :shared
yet — KMP refactor is day-3). Pure-state nav (sealed Screen + stack,
no nav library).

Known polish gaps (day-3): RYD chips render empty on some videos,
description has raw HTML (markdown render needed), no Koin DI yet,
no persistence.

GPL-3.0-or-later per upstream NewPipe.
2026-05-23 19:22:52 -07:00
Kayos
ff4dc6f121 Sulkta day-1: straw — KMP/Compose YouTube client fork
Initial Sulkta fork of NewPipe with a new :strawApp module that ships a
clean Compose-based Android APK at applicationId com.sulkta.straw.

What's here:
- :strawApp — thin Android application shell, MaterialTheme + StrawHome
  Composable. Lives alongside legacy :app so we don't break upstream.
- buildSrc — STRAW_APPLICATION_ID/VERSION constants alongside the existing
  NEWPIPE_APPLICATION_ID_OLD/NEW.
- docs/sulkta — RECON.md (NewPipe codebase breakdown) + DECISIONS.md
  (stack + scope decisions).

NewPipe's :shared KMP scaffold is at 892 LOC and renders nothing; this
fork picks up there and races ahead. Day-1 ships a hello APK; day-2 wires
NewPipeExtractor + Media3 player + SponsorBlock + Return YouTube Dislike.

GPL-3.0-or-later per upstream.
2026-05-23 17:37:55 -07:00