straw/buildSrc/src/main/kotlin/ProjectConfig.kt
Cobb e4a8751942 reliability + security fix sprint (vc=91)
Straw full-audit fix batch (2026-07-04). Confirmed HIGH/MED findings:

- Updater self-brick: drop the fdroid.sulkta.com leaf+E7 SPKI pins (LE
  rotates the leaf every ~90d + intermediates from a pool → a routine
  renewal was guaranteed to miss both pins and silently kill the only
  in-app update path). System-CA validation + the install-time APK
  signature gate remain. Also distinguish "index unreachable" from "up to
  date", add a 30s callTimeout + bounded index read, guard the API-26
  NotificationChannel, and setOnlyAlertOnce.
- Request POST_NOTIFICATIONS at runtime so A13+ update/media notifications
  actually post (was declared but never requested → silent no-op).
- isDebuggable=false on the shipped debug variant (closes ADB run-as dump
  of watch/search history + subs; no package-id cutover).
- Crash fixes: distinctBy(url) on moreFromChannel + related (duplicate
  LazyColumn key); back-handling driven by live nav depth so it survives
  moveTaskToBack on A12+; PlaylistsStore per-store caps + off-main
  hydration (hostile import ANR); SponsorBlock staleness fence via
  NowPlaying.refreshIfCurrent.
- CacheCap.nearest() snaps up to the nearest finite cap (defaults no longer
  resolve to Unlimited/100k on fresh installs).
- RYD/SB FFI shims wrap the uniffi call (swallow a core panic per contract).
- Rust wrapper: release panic="unwind" (was "abort", which defeated UniFFI
  catch_unwind → any core panic = whole-app SIGABRT) + a 60s wall-clock
  timeout on every blocking extractor call (unblocks the caller, bounds
  thread pileup). Pairs with the strawcore-core reliability fix.
2026-07-04 07:09:54 -07:00

364 lines
23 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const val STRAW_SDK_COMPILE_MAJOR = 36
const val STRAW_SDK_COMPILE_MINOR = 1
const val STRAW_SDK_TARGET = 35
// Sulkta fork — Straw
//
// vc=91 / 0.1.0-CY — reliability + security fix sprint (straw audit 2026-07-04):
// * The in-app updater no longer self-bricks on a routine cert renewal. It
// used to pin the fdroid.sulkta.com leaf SPKI + the LE "E7" intermediate,
// but the leaf rotates every ~90-day renewal and LE rotates intermediates
// from a pool (they explicitly say don't pin them), so a normal renewal was
// GUARANTEED to miss both pins — silently killing the ONLY update path,
// with the fix reachable only through the channel the break just killed.
// Dropped pinning: default system-CA validation still blocks a MITM, and a
// swapped APK can't install over Straw anyway (the package installer
// verifies it against our signing key). Also: the check now distinguishes
// "couldn't reach the index" from "up to date" (no more "checked just now /
// up to date" while actually blind), added a 30s call timeout + a bounded
// index read, guarded the API-26 NotificationChannel (was a crash on 7.x),
// and setOnlyAlertOnce so a pending update doesn't re-nag every tick.
// * Android 13+ update/media notifications actually appear now:
// POST_NOTIFICATIONS was manifest-declared but never requested at runtime,
// so nm.notify() was a silent no-op on 13+. Requested once on cold start.
// * The published APK is no longer debuggable (isDebuggable=false on the
// shipped debug variant) — closes an ADB/USB `run-as` dump of watch/search
// history + the subscription list. No package-id cutover; updates in place.
// * Crash fixes: (a) opening a video whose channel repeats a videoId in its
// first ~20 entries hard-crashed Compose on a duplicate LazyColumn key —
// moreFromChannel + related now distinctBy url; (b) in-app back died
// permanently after one back-to-Home on Android 12+ (moveTaskToBack doesn't
// destroy the activity, so the disabled callback never re-enabled) — the
// callback is now driven by live nav depth instead of disable-and-redispatch;
// (c) the local-playlists store was uncapped, so a hostile NewPipe import
// could persist a huge blob that ANR'd every cold start — added per-store
// caps (playlists / items / string length) + off-main hydration like
// ResumePositionsStore; (d) a SponsorBlock fetch that resolves after you
// skip to another video no longer hijacks the now-playing item (staleness
// fence).
// * Cache-size caps that first-launch defaulted to "Unlimited" (100k rows,
// because nearest() was exact-match-or-Unlimited over a set the defaults
// weren't in) now snap up to the nearest finite cap.
// * The RYD + SponsorBlock FFI shims wrap the uniffi call, so a core panic
// (now catchable — see below) is swallowed to null/empty per their contract
// instead of crashing video-detail load.
// * Rust FFI wrapper: release profile is panic="unwind" (was "abort" — abort
// defeated UniFFI's catch_unwind + tokio's spawn_blocking JoinError capture,
// turning ANY panic in the extractor core into a whole-app SIGABRT), and
// every blocking extractor call is wrapped in a 60s wall-clock timeout so a
// wedged JS/HTTP path unblocks the caller instead of spinning forever and
// piling detached threads toward tokio's 512 blocking-pool cap. Pairs with
// the strawcore-core reliability fix (bounded rquickjs 64MiB/2MiB/5s +
// invalidate-on-failure player.js self-heal).
//
// vc=90 / 0.1.0-CX — cold-start hydration + status-bar bleed fix + RYD FFI slim:
// * ResumePositionsStore + EnrichmentStore no longer JSON-decode on the main
// thread in Application.onCreate. Resume was the heaviest cold-start cost in
// the app — at its 100k-entry cap the blob is multi-MB and the decode runs
// ~50-100 ms on a low-end device, all on Main before the first frame. Both
// now seed an empty StateFlow and hydrate from disk on the store's own
// PrefsWriter single-thread dispatcher. Mutations that arrive before the
// hydrate finishes DEFER themselves onto that same FIFO dispatcher (a
// `hydrated` flag gate) so they re-run after the load on fully-loaded state
// — correct for adds AND clears (a naive `loaded + live` merge would
// resurrect a clear that landed on the empty seed). Subscriptions/Playlists/
// History/Settings stay eager: tiny (sub-ms) and rendered directly, so
// seeding them empty would flash an empty list every cold start. (audit 2.1)
// * Fixed the video page leaking details/related rows into the status-bar
// strip above the player when scrolled. The detail body fills the screen
// from y=0 (under the status bar) and the player only starts at the
// status-bar inset, so a leading Spacer-as-item let rows scroll up into the
// uncovered strip over the clock/signal. topPadding is now a layout inset on
// the LazyColumn itself, so the scroll viewport begins + clips at the
// player's bottom edge — rows can't enter the strip. (a tester, on-device.)
// * RYD: dropped the dead `rating` + `viewCount` fields from the strawcore
// `RydVotes` FFI Record + the Kotlin shim. Only likes/dislikes were ever
// rendered; RYD's rating just restates the like/dislike ratio and its
// viewCount is a stale aggregate that conflicts with the real YouTube count
// the detail screen already shows — surfacing either would be redundant or
// misleading, so they no longer cross the FFI. (audit #2 L-9)
//
// vc=89 / 0.1.0-CW — pagination-burst fix + finish the SP-write serialization:
// * Hide-shorts no longer drains a whole channel/search in one burst. The
// infinite-scroll trigger is computed from the FILTERED list while loadMore()
// appends to the UNFILTERED one, so a shorts-heavy feed with hide-shorts ON
// kept the trigger hot (the filter stripped each fetched page back to
// ~nothing) and auto-fetched every page back-to-back to the end of the
// continuation. Now it keeps loading while pages are productive (the filtered
// list grows) and stops after 3 consecutive pages that add nothing visible;
// toggling hide-shorts off grows the list and resumes. Applies to both Search
// and Channel. (audit D-3)
// * Finished the vc=88 PrefsWriter migration: the remaining four SP stores
// (Enrichment, SearchCache, Playlists, FeedCache) now serialize their writes
// through the same single-thread-per-store dispatcher. EnrichmentStore is the
// one that mattered — put() runs 8-wide concurrently from the feed-enrichment
// fan-out, so its apply() ordering genuinely raced (a real M-2 instance the
// audit's four-store scope missed). No SP store is left on a bare
// sp.edit().apply() now.
// No user-visible behavior change beyond the pagination bound.
//
// vc=88 / 0.1.0-CV — deferred-hygiene sweep (audit #2 leftovers, no behavior change):
// * SharedPreferences writes across every store (Settings, History, Subs,
// ResumePositions) now route through one PrefsWriter — a single-thread
// dispatcher per store — so the on-disk apply() order matches the in-memory
// CAS order. Previously a Main-thread toggle and an IO-thread settings/history
// import could land their apply() out of order, and ResumePositions detached
// write-ordering entirely via a fresh globalScope.launch per write; a stale
// value could then win the next cold-start load. Each write reads the live
// StateFlow so disk always converges to the latest in-memory state. (M-2)
// * Settings storage-usage sampling (File.length() ×4 + Coil diskCache.size)
// moved off the composition/Main thread into a LaunchedEffect on
// Dispatchers.IO — was real synchronous FS I/O stalling the first Settings
// frame. (L-14)
// * Dead code + stale comments from the vc=85 SB/RYD→Rust migration: Http.kt
// trimmed to just STRAW_USER_AGENT (the OkHttp client + bounded-body reader
// went to Rust); reconciled the network_security_config / feed.rs /
// SubscriptionFeedViewModel / net.rs / CI comments with reality. recencyScore
// now overflow-guards a crafted relative-date. (L-2 / L-4..L-8 / L-15 / L-16)
//
// vc=87 / 0.1.0-CU — audit #2 fix sprint (closes two vc=86 regressions + more):
// * FIX a duplicate-key crash: the Subs feed, Search, and Channel lists key
// their LazyColumn by the video url, but the sources weren't fully deduped
// — a video that appears twice (a collab/cross-post in two subscribed
// feeds, or the same videoId across two search/channel shelves) produced a
// duplicate Compose key → IllegalArgumentException → screen crash. Now the
// subs merge + the Search/Channel FIRST page dedup by url (the continuation
// paths already did). The vc=86 key additions only deduped the append path.
// * Complete the IosSafe end-of-chunk roll: the vc=86 change set
// chunkRemaining=0 on inner EOF expecting the NEXT read() to roll, but
// Media3 stops calling read() after a -1, so a LENGTH_UNSET inner still
// truncated to the first chunk. Now the roll happens WITHIN read() (loop),
// with a progress guard so a no-progress chunk can't spin.
// * SearchCacheStore.clear() is now atomic (updateAndGet), so a concurrent
// record() can't resurrect a just-cleared entry on disk.
// * Gate the YtRelated autoplay branch through isAllowedYtUrl to match the
// stated universal-allowlist invariant; no-op-guard setLatestKnownVersion;
// drop two unused TrackSelectionParameters imports.
// (Deferred to a follow-up: SharedPreferences write-ordering serialization
// across the stores, the migration's dead Http.kt sweep, and a stale-comment
// pass — all low-risk hygiene, none a crash.)
//
// vc=86 / 0.1.0-CT — audit-fix sprint (code-audit HIGH H2 + 5 MED/LOW):
// * Audio-only toggle no longer drops your max-resolution cap. Both the
// fullscreen button and the detail "Audio" pill rebuilt the track-
// selection params from a fresh Builder, wiping the data-saver ceiling;
// they now buildUpon() the existing params so the cap survives.
// * Subscriptions "Refresh" button no longer sticks at "..." forever on a
// warm restart within the cache TTL — refreshIfStale now clears the
// initial loading seed when it decides nothing needs refreshing.
// * Search + Channel result lists carry a stable item key (the video url)
// so appending a page / filtering shorts stops re-binding rows to new
// data (which re-triggered thumbnail loads + shifted scroll).
// * iOS-safe data source: the unknown-length (LENGTH_UNSET) chunk path now
// 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 on any inner that reports no length).
// * strawcore channel_feed_rss now PROPAGATES the real failure (network /
// HTTP / parse) instead of collapsing every error to an empty list, so a
// broken channel fetch is distinguishable from "no new videos" (the
// bulk subscription_feed keeps its per-channel tolerance).
// * Feed recency: a clock-skewed future upload emits "0 seconds ago"
// (parses to top) instead of "just now" (which the Kotlin recency parser
// couldn't read → sank the item to the bottom).
//
// vc=85 / 0.1.0-CS — image caching + SB/RYD → Rust + crash/autoplay fixes:
// * Thumbnails + channel icons stay cached. Coil's default disk cache is
// only 2% of the phone's FREE space, so on a storage-tight device the
// subs feed (the most image-heavy screen) thrashed its cache and
// re-downloaded thumbnails on every visit — the "each load takes a
// second" lag. Pinned an explicit 256 MB image disk cache + a sized
// memory cache via a SingletonImageLoader.Factory.
// * SponsorBlock + Return-YouTube-Dislike clients moved out of Kotlin
// into Rust (strawcore fetchSponsorSegments / fetchRydVotes) — network
// + JSON belong behind the FFI, not in the UI layer. SponsorBlock keeps
// its privacy-preserving SHA-256 hash-prefix lookup. Kotlin is now a
// thin shim mapping the FFI records onto the existing SbSegment/RydVotes
// domain types; behavior identical. (Migration #2 of "all backend → Rust".)
// * FIX a crash: extract_channel_id() sliced the channel URL using a
// length derived from a lowercased copy of itself — to_lowercase() can
// change byte length on non-ASCII, so a channel URL with any non-ASCII
// 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 started a different video in that window, autoplay
// would still fire and replace your choice with the stale next-up. Added
// a staleness fence (same class as the vc=83 minibar fix).
//
// vc=84 / 0.1.0-CR — name + stream-picker → Rust:
// * The app is now just "Straw" in the launcher, not "Straw debug" —
// we're past the debug-branding phase. (The package id stays
// com.sulkta.straw.debug under the hood so updates install in place
// and nobody loses their subscriptions/history; dropping the suffix
// for real is a separate release-track cutover.)
// * The stream-selection logic (pick the playable video/audio/combined
// URLs from an extraction, honoring the resolution cap) moved out of
// Kotlin into the Rust strawcore layer (resolvePlayback). The Kotlin
// side is now a thin shim that supplies the cap and attaches
// SponsorBlock segments. Behavior is identical — same URLs picked,
// same data-saver fallback — it's just backend logic living in Rust
// where it belongs. First step of moving all backend logic to Rust.
//
// vc=83 / 0.1.0-CQ — fix: swapping videos from the minibar kept playing
// the old one:
// * With a video minimized to the bottom minibar, picking a different
// video updated the title, details and related list but the OLD video
// kept playing. The inline player's resolve→play effect read the
// shared (activity-scoped) view-model's `resolved` stream without
// checking it actually belonged to the newly-opened video. For one
// frame after the swap the view-model still holds the previous video's
// resolved URLs, so playback was claimed under the NEW url while
// streaming the OLD media — and the correct re-fire was then swallowed
// by the "already playing this url" short-circuit, so the new video
// never started. Restored the loadedUrl fence (the same guard
// VideoDetailBody and every ViewModel already use) that the vc=75
// expandable-player rearchitect had dropped from the inline wiring.
//
// vc=82 / 0.1.0-CP — subscription-feed enrichment goes lightweight:
// * Filling in a feed row's view count + duration used to run the FULL
// stream extraction per item (the same path opening a video does):
// Android /player, the JS signature/nsig deobfuscation, an extra WEB
// /player metadata round-trip, plus stream/manifest/caption parsing —
// then threw all of it away except those two numbers. On a refresh
// that touched dozens of items that was dozens of redundant heavy
// round-trips.
// * Now it calls a new strawcore stream_metadata() path that does only
// the Android /player fetch + the videoDetails read those two fields
// come from, skipping the JS eval, the extra WEB round-trip, iOS, and
// stream extraction. The values are identical (they come from the same
// videoDetails the full path used), the feed just stops paying for
// work it discarded. (strawcore 30f24d2.)
//
// vc=81 / 0.1.0-CO — perf-audit app-side batch (no behavior change):
// * Search: the reactive cache-preview filter no longer runs on the
// main thread on every keystroke. It walked the entire 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 it can't clobber live
// results.
// * Subscription feed: the merge 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.
//
// vc=80 / 0.1.0-CN — strawcore extraction perf (Rust batch):
// * The extractor borrows the streamingData subtree out of the Android
// + iOS player responses instead of deep-cloning the largest part of
// each response, and merges format objects by reference rather than
// cloning all ~20-40 of them per video open.
// * Channel pages fetch their Home + Videos tabs concurrently, so
// opening a channel costs one network round-trip of latency instead
// of two.
// * Response bodies decode in place on the (overwhelmingly common)
// valid-UTF-8 path instead of always copying.
// No behavior change — purely allocation + latency wins in strawcore
// (strawcore 91d4824).
//
// vc=79 / 0.1.0-CM — perf-audit pass-2 (app-side slam-dunks):
// * FIX a wrong-thread crash: the headphone-disconnect settings watcher
// ran on the IO scope and touched the ExoPlayer (thread-affine to the
// Main thread it was built on) → "Player accessed on the wrong thread"
// latent on every session. Collector now runs on Dispatchers.Main.
// * recordSearch (json-encode + SharedPrefs write) moved off the Main
// thread into withContext(Dispatchers.IO).
//
// vc=78 / 0.1.0-CL — perf-audit batch 1 (app-side):
// * Autoplay-next no longer drops the user's max-resolution cap. The
// enter-video track-selection reset built params from scratch, wiping
// the data-saver ceiling on every URL change; now it re-enables the
// video track surgically + re-asserts applyMaxResolutionCap().
// * VideoDetail body is now a LazyColumn, not a verticalScroll Column.
// The related + more-from-channel lists recycle and defer each row's
// image decode + its two progress-overlay flow collectors until
// scrolled into view (was ~40 decodes + ~80 collectors mounted eagerly
// on every video open). Namespaced item keys; dialogs hoisted out.
//
// vc=77 / 0.1.0-CK — morph perf: static poster during collapse/morph:
// * The minibar + the whole collapse/expand morph now render the video's
// static poster, not the live TextureView. Scaling a live-playing
// TextureView through the morph's graphicsLayer every frame was the
// remaining sluggishness; the live PlayerView only mounts once settled
// fully expanded. Audio is unaffected (it's in the foreground service).
//
// vc=76 / 0.1.0-CJ — expandable-player smoothness pass:
// * The detail body no longer renders to an offscreen buffer every
// frame during the morph (CompositingStrategy.ModulateAlpha) — that
// offscreen alpha pass on the whole scroll subtree was the main
// cause of the sluggish feel.
// * Morph animation is now a snappy no-bounce spring instead of a
// 300ms FastOutSlowIn tween (which ramped slowly at both ends).
//
// vc=75 / 0.1.0-CI — expandable player (full rearchitect):
// * The video page and the bottom minibar are now ONE container that
// morphs continuously between them, both directions. Replaces the
// old separate Screen.VideoDetail page + MinibarOverlay (which just
// appeared/vanished). One fraction (0=minibar, 1=full page) drives a
// graphicsLayer scale+translate on a single mounted TextureView, so
// the morph runs in the render phase (smooth) and the same video
// surface stays live across the whole range — a true shared-element
// transition. Swipe the player down → it shrinks into the toolbar;
// swipe/tap the toolbar up → it grows back into the page.
// * Opening a video is no longer a nav push — it sets OpenVideo +
// expands. The browse screen underneath stays put, so collapsing
// drops you right back where you were.
// * Playback plumbing unchanged: shared controller, NowPlaying,
// setPlayingFrom, SponsorBlock, autoplay-next, PiP, background audio,
// and the true-fullscreen Player (⛶) all still key off NowPlaying.
//
// vc=73 / 0.1.0-CG — VideoDetail cleanup:
// * Inline player → TextureView surface so the swipe-down-to-minimize
// drag is smooth (a SurfaceView won't follow the Compose graphicsLayer
// transform — that was the stutter).
// * Description folded into a collapsible "Details" section, collapsed
// by default, sitting just above the recommendations.
// * Action buttons restyled into one tidy horizontally-scrollable row
// of uniform icon pills (dropped the redundant "Play").
//
// vc=23 / 0.1.0-AI — minibar + downloads UI + green theme:
// * MediaController/MediaSessionService unification — single ExoPlayer
// owned by PlaybackService, every UI surface is a controller client.
// Inline player on VideoDetail, fullscreen Player, and the new
// minibar overlay all drive the same underlying player; nothing
// restarts on screen transitions.
// * Persistent minibar overlay at the bottom of every non-Player
// screen whenever something is loaded. Tap → expand to fullscreen.
// Drag-down on fullscreen → minimize to minibar. ⌄ overlay button
// also minimizes. × on the minibar stops + clears.
// * Downloads page wired into the drawer.
// * Theme: forest-green primary palette in place of M3 default
// lavender / NewPipe red — modern, clean, distinct.
//
// vc=22 / 0.1.0-AH — V-2 player polish + local playlists:
// * Inline → fullscreen now hands off seek position. Tap Play (or the
// ⛶ pill on the inline player) while the inline is mid-track and
// the fullscreen Player picks up at the same point. Same handoff
// pattern as fullscreen → background from vc=21.
// * Local playlists: drawer entry "Playlists", "Save" button on
// VideoDetail. SharedPreferences-backed, no queue/autoplay yet
// (tap an entry to open VideoDetail as normal).
//
// vc=21 / 0.1.0-AG — player hand-off polish:
// * 🎧 background-audio button now captures the current position and
// resumes the foreground service from there instead of restarting.
// * HOME / recents button while on the player now hands off seamlessly
// to background audio (same position-preserving path) instead of
// auto-entering Picture-in-Picture. Manual PiP via the ⊟ overlay
// button is unchanged.
//
// vc=20 / 0.1.0-AF — channel-videos fix on top of the rust pipeline
// cutover. vc=19 returned empty subscription feeds because
// strawcore-core's channel_info wasn't doing the second browse for the
// Videos tab AND wasn't parsing the new lockupViewModel shape.
//
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 91
const val STRAW_VERSION_NAME = "0.1.0-CY"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"