diff --git a/README.md b/README.md index aa4332165..6afc33878 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,77 @@ -

We are rewriting large chunks of the codebase, to bring about a modern and stable NewPipe! You can download nightly builds here.

-

Please work on the refactor branch if you want to contribute new features. The current codebase is in maintenance mode and will only receive bugfixes.

+# Straw -

-

NewPipe

-

A libre lightweight streaming front-end for Android.

+A Sulkta fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe). Android YouTube +client, Compose UI, Media3 player, with [SponsorBlock](https://sponsor.ajay.app/) +and [Return YouTube Dislike](https://returnyoutubedislike.com/) baked in. -

Get it on F-Droid

+The extractor is `strawcore`, a Rust port of NewPipeExtractor exposed to Kotlin +via UniFFI. No InnerTube/JS deobf code path lives on the JVM anymore. -

- - - - - - -

+## Install -

- - -

+F-Droid repo: -
-

ScreenshotsSupported ServicesDescriptionFeaturesInstallation and updatesContributionDonateLicense

-

WebsiteBlogFAQPress

-
+Add the repo in your F-Droid client of choice, then install Straw. -*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)* +The app also self-updates from the same repo when an APK lands there with a +higher `versionCode`. -> [!warning] -> THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE. -> -> PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS. +## What's in -## Screenshots +- Search, video detail, channel pages, playlists +- Inline player + fullscreen + minibar + background audio + PiP +- Media3 ExoPlayer (DASH / HLS / progressive / merged DASH chunks) +- SponsorBlock auto-skip (categories user-toggleable) +- Return YouTube Dislike on video detail +- RSS-based subscription feed (fast — ~1s for 50 subs) +- Hide-shorts / hide-paid / hide-age-restricted feed filters +- Resume positions + watch history + search history +- Local playlists, downloads (video + audio) +- NewPipe-format settings import (subs + playlists + history) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png) -

-[](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png) -[](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png) +## What's out (on purpose) -### Supported Services +- Trending / algorithmic feeds. Subscriptions only. +- iOS / desktop targets. Android-only for now. +- Google Play Services anything. -NewPipe currently supports these services: +## Layout - -* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube)) -* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube)) -* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp)) -* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud)) -* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club)) - -As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile! - -Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube. - -If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor). - -## Description - -NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe. - -Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed. - -### Features - -* Watch videos at resolutions up to 4K -* Listen to audio in the background, only loading the audio stream to save data -* Popup mode (floating player, aka Picture-in-Picture) -* Watch live streams -* Show/hide subtitles/closed captions -* Search videos and audios (on YouTube, you can specify the content language as well) -* Enqueue videos (and optionally save them as local playlists) -* Show/hide general information about videos (such as description and tags) -* Show/hide next/related videos -* Show/hide comments -* Search videos, audios, channels, playlists and albums -* Browse videos and audios within a channel -* Subscribe to channels (yes, without logging into any account!) -* Get notifications about new videos from channels you're subscribed to -* Create and edit channel groups (for easier browsing and management) -* Browse video feeds generated from your channel groups -* View and search your watch history -* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing) -* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service) -* Download videos/audios/subtitles (closed captions) -* Open in Kodi -* Watch/Block age-restricted material - - - - -## Installation and updates -You can install NewPipe using one of the following methods: - 1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/ - 2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases), [compare the signing key](#apk-info) and install it. - 3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users. - 4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods. - 5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up. - -We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK. - -In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure: -1. Back up your data via Settings > Backup and Restore > Export Database so you keep your history, subscriptions, and playlists -2. Uninstall NewPipe -3. Download the APK from the new source and install it -4. Import the data from step 1 via Settings > Backup and Restore > Import Database - -> [!Note] -> When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing. - -### APK Info - -This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2. ``` -CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB +strawApp/ Sulkta-authored app — Compose UI, Media3 wiring, SB + RYD clients +rust/ strawcore — UniFFI wrapper around the Rust extractor +shared/ KMP scaffold inherited from upstream NewPipe (unused for now) +app/ Upstream NewPipe :app module — kept for reference ``` -## Contribution -Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md). +## Build - -Translation status - +``` +./gradlew :strawApp:assembleDebug +``` -## Donate -If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate). +Requires the Rust toolchain plus the four Android targets: - - - - - - -
LiberapayVisit NewPipe at liberapay.comDonate via Liberapay
+``` +rustup target add aarch64-linux-android armv7-linux-androideabi \ + x86_64-linux-android i686-linux-android +cargo install cargo-ndk uniffi-bindgen +``` -## Privacy Policy - -The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/). +…and `ANDROID_NDK_HOME` pointing at NDK r27c (or newer). The Gradle build runs +`cargo ndk` + `uniffi-bindgen` automatically. ## License -[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) -NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +GPL-3.0-or-later, inherited from upstream NewPipe. + +## Upstream + +This repo tracks . Upstream changes +get pulled periodically via the `upstream` remote. + +## Disclaimer + +Not affiliated with YouTube, Google, NewPipe e.V., the SponsorBlock project, +or Return YouTube Dislike. Trademarks belong to their owners. Straw uses +public web endpoints; nothing here authenticates to any account. diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7ee7f3ec7..880e1f4b7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -14,7 +14,7 @@ members = ["strawcore"] edition = "2021" license = "GPL-3.0-or-later" authors = ["Sulkta-Coop"] -repository = "http://192.168.0.5:3001/Sulkta-Coop/straw" +repository = "https://git.sulkta.com/Sulkta-Coop/straw" [profile.release] # Strip debug info, run thin LTO. APK size matters more than build time here. @@ -29,6 +29,6 @@ opt-level = "z" url = "2" [profile.dev] -# Keep debug builds fast — we're rebuilding constantly during U-1..U-5. +# Keep debug builds fast — we rebuild often during NDK cross-compile. opt-level = 0 debug = 1 diff --git a/rust/README.md b/rust/README.md index 2aa515b4c..720f81fa9 100644 --- a/rust/README.md +++ b/rust/README.md @@ -20,12 +20,12 @@ moves to Rust. ## Build chain ``` -crafting-table +Build container (Sulkta uses one; any toolchain matching this layout works) ├── rustup stable (target add: aarch64-linux-android, armv7-linux-androideabi, │ x86_64-linux-android, i686-linux-android) ├── cargo-ndk (cross-compile helper) ├── android-sdk (ANDROID_HOME, sdkmanager, build-tools, platforms) -└── android-ndk (ANDROID_NDK_HOME, r27c LTS at /caches/android-sdk/ndk/...) +└── android-ndk (ANDROID_NDK_HOME, r27c LTS) Gradle (strawApp/build.gradle.kts) ├── cargoBuild Exec task → cargo ndk -t ... -o jniLibs/ build --release diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index 047f10f69..6ccd53fd5 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -30,14 +30,14 @@ strawcore-core = { path = "../../../strawcore" } # Android target has no pre-generated bindings — flip on the `bindgen` # feature so cargo regenerates at build time. Direct dep so the feature # flag propagates (cargo's unified feature resolver lifts this to the -# transitive use). Crafting-table has libclang preinstalled. +# transitive use). Build host needs libclang installed. rquickjs-sys = { version = "0.11", default-features = false, features = ["bindgen"] } # Error glue. thiserror = "1" # Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`. log = "0.4" android_logger = { version = "0.14", default-features = false } -# vc=56 — subscription RSS feed fan-out. reqwest dedupes against +# subscription RSS feed fan-out. reqwest dedupes against # strawcore-core's already-pulled reqwest; quick-xml is small (~200KB); # futures for buffer_unordered. rustls-tls avoids the NDK openssl headers # headache. diff --git a/rust/strawcore/src/error.rs b/rust/strawcore/src/error.rs index 7b840fbe3..2703813b5 100644 --- a/rust/strawcore/src/error.rs +++ b/rust/strawcore/src/error.rs @@ -69,7 +69,7 @@ impl From for StrawcoreError { // catches googlevideo.com hosts. The challenge URL // itself still solves without `continue=`, so the // user can tap to unblock without leaking the - // signature/expire/pot token. Round-4 audit LOW-1. + // signature/expire/pot token. StrawcoreError::RequiresLogin { detail: format!("reCAPTCHA challenge: {}", strip_continue_param(&url)), } diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index 54a372df5..c50b2942c 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -1,4 +1,4 @@ -// vc=56 — fast subscription feed via YouTube's per-channel RSS endpoint. +// fast subscription feed via YouTube's per-channel RSS endpoint. // // YouTube serves `https://www.youtube.com/feeds/videos.xml?channel_id=UCxxx` // — small Atom XML, no auth, no JS, no InnerTube round-trip. Replaces the @@ -28,18 +28,15 @@ const PER_CHANNEL_TIMEOUT_S: u64 = 8; /// Cap on the body bytes we'll read for a single RSS fetch. Real YT /// Atom feeds are ~5-30 KB; 2 MiB leaves comfortable headroom while /// blocking a hostile or compromised host from streaming GB-scale -/// bodies into JVM memory inside the 8s timeout. Round-67 audit -/// rust-HIGH-5. +/// bodies into JVM memory inside the 8s timeout. const RSS_MAX_BYTES: usize = 2 * 1024 * 1024; /// Cap on parsed entries per channel — RSS normally returns 15. /// 50 leaves headroom for one-off legitimate variance; anything /// past that is a sign the feed isn't what we expect. -/// Round-67 audit rust-MED-6. const RSS_MAX_ENTRIES: usize = 50; /// Year range we trust civil-to-days math for. Strawcore RSS only /// emits real-world recent uploads; clamping here turns adversarial /// year fields into a parse failure rather than i64 overflow. -/// Round-67 audit rust-CRIT-1. const YEAR_MIN: i32 = 1970; const YEAR_MAX: i32 = 2200; @@ -48,7 +45,7 @@ const YEAR_MAX: i32 = 2200; /// items after the RSS-fed paint to fill in the gaps that /// channel_feed_rss leaves empty. /// -/// vc=66 — built specifically so the subs feed can show 'N views · +/// built specifically so the subs feed can show 'N views · /// X duration' the way YT does, without paying the full channel_info /// page-scrape cost on initial paint. The underlying stream_info IS /// heavier than we'd like (~500ms each, runs JS deobf for play URLs @@ -75,7 +72,7 @@ pub async fn enrich_feed_item( /// Shared reqwest Client — DNS resolver + TLS keepalive + connection /// pool live here so a 50-channel fan-out reuses one pool instead of -/// paying 50 handshakes. Round-67 audit rust-HIGH-4. +/// paying 50 handshakes. static RSS_CLIENT: OnceLock = OnceLock::new(); fn rss_client() -> Result<&'static Client, StrawcoreError> { @@ -86,7 +83,7 @@ fn rss_client() -> Result<&'static Client, StrawcoreError> { .timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S)) .user_agent(concat!("Mozilla/5.0 (Android; Mobile; Straw/", env!("CARGO_PKG_VERSION"), ")")) // Cap redirect chains so a misconfigured/hostile feed can't - // spin a server out of our 8s budget. Round-67 audit rust-LOW-8. + // spin a server out of our 8s budget. .redirect(reqwest::redirect::Policy::limited(3)) .build() .map_err(|e| StrawcoreError::Extractor { @@ -133,9 +130,9 @@ pub async fn subscription_feed( // Per-channel ordering is RSS-served-newest-first. Cross-channel // interleave is the caller's responsibility — Kotlin's mergeFromCache // sorts by parsed recency, which is the source of truth. Returning - // the flat list as-is. (vc=66 prior code sorted lexicographically + // the flat list as-is. (an earlier version sorted lexicographically // on the relative-date STRING, which is wrong because "10 hours - // ago" < "2 hours ago" in cmp order — round-67 audit rust-HIGH-6.) + // ago" < "2 hours ago" in cmp order) Ok(results.into_iter().flatten().collect()) } @@ -150,13 +147,13 @@ async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option Option { use futures::StreamExt; let mut total = 0usize; @@ -168,8 +165,7 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { // large (HTTP allows multi-GiB chunks). Reject any one chunk // bigger than the whole body cap before we even add it to the // running total — protects against hyper having already - // allocated the chunk on our behalf. Round-68 audit - // rust-HIGH-1. + // allocated the chunk on our behalf. if chunk.len() > RSS_MAX_BYTES { log::warn!("strawcore::rss single chunk {} exceeds cap; aborting", chunk.len()); return None; @@ -181,7 +177,7 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { } buf.extend_from_slice(&chunk); } - // Lossy decode — round-68 audit rust-HIGH-2. A strict from_utf8 + // Lossy decode — A strict from_utf8 // returns None on any invalid byte, so a single mojibake title // would silently drop the entire channel from the feed. quick-xml // tolerates U+FFFD replacement chars and the per-entry skip-on- @@ -199,7 +195,6 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { /// * raw `UCxxx...` (already an ID) /// /// Real YT channel IDs are EXACTLY 24 chars (`UC` + 22 base64-ish). -/// Round-67 audit rust-HIGH-1. /// /// `@handle` URLs are NOT supported here — RSS requires the channel ID. /// Callers with @handles should resolve via channel_info() once and @@ -210,7 +205,7 @@ fn extract_channel_id(input: &str) -> Option { // Match the ":///channel/" prefix in a single sweep // so we accept http/https + www./m. variants without four-way // string-strip ladders. ANCHORED at the start of the string — - // round-68 audit rust-HIGH-3: prior `find()` accepted any input + // prior `find()` accepted any input // containing the prefix as a substring, so a pasted // `evil.com/?redir=https://www.youtube.com/channel/UCxxx` would // silently rewrite to the wrong channel. @@ -237,7 +232,7 @@ fn extract_channel_id(input: &str) -> Option { } /// A real YouTube channel ID is `UC` followed by exactly 22 chars from -/// `[A-Za-z0-9_-]`. Round-67 audit rust-HIGH-1. +/// `[A-Za-z0-9_-]`. fn validate_channel_id(id: &str) -> Option { if id.len() != 24 || !id.starts_with("UC") { return None; @@ -343,7 +338,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { // Skip entries missing the load-bearing fields — // an empty title renders as a blank card the user // can't tap, and an empty published collapses the - // recency sort. Round-67 audit rust-HIGH-2. + // recency sort. if !video_id.is_empty() && !title.is_empty() && !published.is_empty() { items.push(SearchItem { url: format!("https://www.youtube.com/watch?v={video_id}"), @@ -360,7 +355,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { // RSS gives RFC3339 timestamps. Convert to // the human-relative format Kotlin's // recencyScore parser expects ("N units - // ago"). vc=56 was passing the raw ISO + // ago"). An earlier build was passing the raw ISO // through, which broke the sort comparator // — every item tied at MIN_VALUE so the // feed order was effectively random; LTT + @@ -371,7 +366,6 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { if items.len() >= RSS_MAX_ENTRIES { // Defense-in-depth against a feed that // ships thousands of blocks. - // Round-67 audit rust-MED-6. return Some(items); } } @@ -387,7 +381,6 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { // collected rather than throwing the whole batch away. // A truncated body (EOF mid-stream on a flaky network) // would otherwise silently disappear the channel. - // Round-67 audit rust-CRIT-3. Err(e) => { log::warn!("strawcore::rss parse error after {} items: {e}", items.len()); return Some(items); @@ -428,7 +421,7 @@ fn iso_to_relative(iso: &str) -> String { // top, which is the LTT/WTYP-recurrence vector. Treat future // dates as "just now" so the relative-string sort behaves and // a single skewed item doesn't pin itself at the top of the - // feed. Round-67 audit rust-HIGH-7. + // feed. if secs > now_secs { return "just now".to_string(); } @@ -455,7 +448,6 @@ fn parse_rfc3339_secs(s: &str) -> Option { // Year clamp BEFORE civil_to_days — out-of-range years overflow // the era arithmetic in debug, wrap in release. A hostile feed // serving year=2147483647 must not produce junk timestamps. - // Round-67 audit rust-CRIT-1. if !(YEAR_MIN..=YEAR_MAX).contains(&y) { return None; } diff --git a/rust/strawcore/src/runtime.rs b/rust/strawcore/src/runtime.rs index 7e1ef14c8..336d6b4bb 100644 --- a/rust/strawcore/src/runtime.rs +++ b/rust/strawcore/src/runtime.rs @@ -3,7 +3,7 @@ // strawcore-core Downloader + Localization singleton so the extractor // has an HTTP client to use. // -// Round-4 audit HIGH-1: the prior shape used `Once::call_once` and +// the prior shape used `Once::call_once` and // silently swallowed errors. If the FIRST call ran while the network // stack wasn't ready (cold boot in airplane mode, SELinux denial on // first TLS init, transient resolver failure), the Once slot was @@ -60,7 +60,7 @@ pub fn ensure_initialized() { // DownloaderMissing once from the extractor and recover on // the next user action; the alternative (blocking N tokio // workers for the full duration of a slow init) freezes the - // UI. Round-6 audit HIGH-2 was the regression on round-5's + // UI. was the regression on round-5's // mutex-first ordering. let _guard = match INIT_LOCK.try_lock() { Ok(g) => g, diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs index 056af6977..f99c29da9 100644 --- a/rust/strawcore/src/search.rs +++ b/rust/strawcore/src/search.rs @@ -58,9 +58,9 @@ pub async fn search(query: String) -> Result, StrawcoreError> { // names, sometimes embarrassing) and android_logger emits at // info-level in release builds, which means they'd ride the // Settings → Export Logs path straight into a user's chat. Log - // shape, not content. vc=36 audit CVE HIGH-2. + // shape, not content. log::info!("strawcore::search query_len={}", query.len()); - // Round-5 audit MED-1: ensure_initialized was only wired into + // ensure_initialized was only wired into // init_logging() so the 5s-backoff retry path never fired from // the hot entry points. Now every extractor entry re-asserts // — cheap when INITIALIZED is true (single Acquire load). diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index e6458448e..13fbac20b 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -154,7 +154,7 @@ dependencies { // - cargo + rustup with the four Android targets installed // - cargo-ndk on PATH // - ANDROID_NDK_HOME pointing at an NDK with the right toolchains -// All of that lives in the crafting-table container. +// All of that lives in the Sulkta build container. // ============================================================================= val rustRoot = file("../rust").absolutePath @@ -166,9 +166,10 @@ val cargoBin: String = "$cargoHome/bin/cargo" val ndkHome: String = System.getenv("ANDROID_NDK_HOME") ?: System.getenv("ANDROID_NDK_ROOT") ?: "/caches/android-sdk/ndk/27.2.12479018" -// Honor CARGO_TARGET_DIR if set (we redirect it to /caches on crafting-table -// because the container's writable rootfs hits 100% before the cross-compile -// for 4 ABIs finishes). Falls back to the default `/target`. +// Honor CARGO_TARGET_DIR if set (our build container redirects it to a +// cache mount because the container's writable rootfs hits 100% before +// the cross-compile for 4 ABIs finishes). Falls back to the default +// `/target`. val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR") ?: "$rustRoot/target" diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml index e751d37f5..56adc1f94 100644 --- a/strawApp/src/main/AndroidManifest.xml +++ b/strawApp/src/main/AndroidManifest.xml @@ -38,11 +38,11 @@ + with ALLOWED_YT_HOSTS in util/YtUrl.kt (canonical home). + Was previously inlined in StrawActivity.kt under YT_HOSTS; + the two lists drifted (music.youtube.com etc. accepted by + code but never offered by the launcher disambig), so the + canonical list lives in one place now. --> @@ -63,11 +63,11 @@ - + @@ -74,7 +74,7 @@ class StrawApp : Application() { Playlists.init(this) Resume.init(this) FeedEnrichment.init(this) - // vc=36 audit HIGH-R3: FeedCache (~225 KB) + SearchCache + // FeedCache (~225 KB) + SearchCache // (~150 KB) JSON-decode at construction. Stash the // applicationContext eagerly (cheap) so `get()` is callable // anywhere; the actual store construction (and the disk @@ -83,7 +83,7 @@ class StrawApp : Application() { // main thread. FeedCache.init(this) SearchCache.init(this) - // vc=36 audit CVE HIGH-5: sweepStale's deleteRecursively() + // sweepStale's deleteRecursively // can walk ~256 MB if a previous import was LMK-killed // mid-extraction. Strictly off the main thread. appScope.launch { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 94ac9fe69..d62c68852 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -295,7 +295,7 @@ private fun SubsPane( LaunchedEffect(subs) { feedVm.refreshIfStale() } // Filter + pagination state. hideWatched is sticky for the session - // (no SharedPreferences yet — easy to add if Cobb wants persistence). + // (no SharedPreferences yet — easy to add if persistence is wanted). // visibleCount starts at PAGE_SIZE and grows by PAGE_SIZE every time // the scroll passes ~5 items from the bottom of what's currently // visible. @@ -324,7 +324,7 @@ private fun SubsPane( } } // remember the page-slice so we don't allocate a new ArrayList on - // every recomposition (scroll hitch vc=67). + // every recomposition (scroll hitch). val displayed = remember(filteredItems, visibleCount) { filteredItems.take(visibleCount) } @@ -373,7 +373,7 @@ private fun SubsPane( Spacer(modifier = Modifier.height(16.dp)) // Show a slim error banner above cached items even if we have data — - // audit HIGH-7: previously a 401/429 looked identical to a successful + // previously a 401/429 looked identical to a successful // refresh because the error chip was hidden whenever items != empty. if (feed.error != null && feed.items.isNotEmpty()) { Text( @@ -425,7 +425,7 @@ private fun SubsPane( // (displayed.size, hasMore) was mutated BY this effect, // which cancelled the snapshotFlow collector mid-stream // and produced the "scrolled to bottom, nothing loads" - // bug from the vc=34 audit. + // bug from the audit. // // hasMore and filteredItems are read inside the // snapshotFlow producer (not closed over from outside) @@ -598,7 +598,7 @@ private fun SubChip( // width breaks the prior 2-line wrap mid-word ("NoCopyrightS // / ounds", "DEFCONConfe / rence") — uglier than a clean // "NoCopyrigh…". Centered text alignment so the ellipsis - // sits over the chip's icon column. vc=64. + // sits over the chip's icon column. Text( text = ch.name, style = MaterialTheme.typography.labelSmall, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt index 5851fda9c..597688f1e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt @@ -76,7 +76,7 @@ class EnrichmentStore(context: Context) { ) val before = _entries.value val next = _entries.updateAndGet { current -> - // Round-67 audit HIGH-4: short-circuit when the cached + // short-circuit when the cached // value is already the same view+duration — re-enriching // within TTL otherwise allocates a new Map every call // and the `before !== next` guard never triggers, so a @@ -110,7 +110,7 @@ class EnrichmentStore(context: Context) { private fun load(): Map = runCatching { val s = sp.getString(KEY, null) ?: return emptyMap() val loaded = json.decodeFromString>(s) - // Round-67 audit MED-6: prune TTL-expired entries on load + // prune TTL-expired entries on load // so the store doesn't accumulate dead weight up to // MAX_ENRICHMENTS over time. `Forever` TTL skips the prune. val ttl = Settings.get().cacheTtl.value diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt index af886ccf5..eab61b975 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -40,7 +40,7 @@ class FeedCacheStore(context: Context) { /** * Snapshot of the disk cache, filtered by the user-configured TTL. - * Returns empty map if nothing saved or everything expired. vc=59 — + * Returns empty map if nothing saved or everything expired. * Settings.cacheTtl.isForever short-circuits the filter; finite TTLs * drop entries whose fetchedAt is older than (now - ttl). */ @@ -73,7 +73,7 @@ object FeedCache { * (and the ~225 KB JSON decode that happens at construction) is * deferred until the first `get()` call. Lets Application.onCreate * return quickly while every caller still gets a valid Store — - * vc=36 audit HIGH-R3. Callers should access from a coroutine + * Callers should access from a coroutine * (IO dispatcher) where the lazy construction cost is acceptable. */ fun init(context: Context) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 9e70c7101..1eb1bd2f8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -33,7 +33,7 @@ private const val KEY_WATCHES = "watches_v1" private const val KEY_SEARCHES = "searches_v1" /** - * Pre-vc=59 hard limits. Still used as the absolute upper bound when + * Earlier hard limits. Still used as the absolute upper bound when * Settings.historyWatchesCap is CacheCap.Unlimited — we don't want to * allow truly-uncapped growth that could OOM SP on a hostile import. * Any user-picked cap above this is silently floored to MAX_*_HARD. @@ -75,14 +75,14 @@ class HistoryStore(context: Context) { /** * Bulk import. Callers (currently SettingsImport) feed - * oldest→newest. Single SP write — vc=34 audit flagged the + * oldest→newest. Single SP write audit flagged the * per-row recordWatch in importHistory as a write-storm vector. * * Walks input newest-first (input is fed oldest-first), filters * blanks + already-seen videoIds, prepends to current, then takes * maxWatches(). Imports WIN over older current entries when the - * store is at the cap — the vc=37 first cut silently discarded - * the whole import in that case (round-3 audit HIGH-1). + * store is at the cap — the the first cut silently discarded + * the whole import in that case. * * Skips the SP write when the resulting list is identical (by * reference equality after updateAndGet's no-op return) so a @@ -91,7 +91,7 @@ class HistoryStore(context: Context) { /** * Returns the number of fresh items actually folded into the * store on this call (counts new videoIds; duplicates of - * already-recorded entries don't count). Round-4 audit HIGH-7 — + * already-recorded entries don't count). * SettingsImport previously reported `size_after - size_before` * which lies when the store was at maxWatches() (post-state can * be 50 = pre-state even when 20 imports landed and 20 older @@ -104,7 +104,7 @@ class HistoryStore(context: Context) { val next = _watches.updateAndGet { current -> // Reset the counter inside the CAS lambda so a retry // doesn't accumulate across attempts — same shape as - // SubscriptionsStore.addAll's vc=37 round-3 fix. + // SubscriptionsStore.addAll's round-3 fix. counter.set(0) val seen = HashSet(current.size + items.size) current.forEach { seen.add(it.videoId) } @@ -134,9 +134,8 @@ class HistoryStore(context: Context) { /** * Bulk import for search history. Same pattern as * recordAllWatches — single SP write regardless of input size. - * vc=37 round-3 audit CVE-MED-6: SettingsImport.importHistory was - * calling recordSearch per row, producing N SP writes on a - * potentially-100k-row import. + * SettingsImport.importHistory previously called recordSearch per + * row, producing N SP writes on a potentially-100k-row import. */ /** * Returns the number of fresh queries actually folded into the diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt index e6cc3aa7a..26ba0e16d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PlaylistsStore.kt @@ -69,7 +69,6 @@ class PlaylistsStore(context: Context) { * addItem() in a loop — both write SP, and addItem walks every * playlist linearly per insert. A 100-playlist × 100-items * NewPipe export was ~10,001 SP commits + ~10M comparisons. - * Round-4 audit HIGH-2. */ fun importPlaylist(name: String, items: List): Playlist { val stampNow = System.currentTimeMillis() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt index 9ef0c07bc..ffa02cd5f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -38,7 +38,7 @@ private const val PREFS = "straw_resume_positions" private const val KEY_POSITIONS = "positions_v1" /** - * Pre-vc=59 hard cap. Now a ceiling rather than a fixed value: the + * Earlier hard cap. Now a ceiling rather than a fixed value: the * user-picked cap from Settings.resumePositionsCap is silently floored * to this so even "Unlimited" doesn't OOM SP. Bigger ceiling here * than HistoryStore because resume entries are tiny (~50 bytes each) @@ -97,7 +97,7 @@ class ResumePositionsStore(context: Context) { ) val before = _positions.value val next = _positions.updateAndGet { current -> - // Round-67 audit HIGH-6: short-circuit value-equality — + // short-circuit value-equality // a 5s poll tick that finds the same (position, duration, // wall-time) for an existing entry returns `current` // unchanged so the outer `next !== before` guard @@ -117,7 +117,7 @@ class ResumePositionsStore(context: Context) { val withEntry = current + (videoId to entry) // Skip sort+associate when we're under the cap (the // common case at default 500). Sort is O(n log n); - // associate allocates another map. Round-67 audit HIGH-6. + // associate allocates another map. if (withEntry.size > maxResumes()) { // Drop oldest by lastWatchedAt — newcomers always land // because the entry we just added is by definition the @@ -133,8 +133,7 @@ class ResumePositionsStore(context: Context) { if (next !== before) { // JSON encode + SP write off Main — encoding 100k entries // would be ~50-100 ms on a low-end device, and the 5s - // captureResumePosition poll runs on Main. Round-67 - // audit HIGH-6. + // captureResumePosition poll runs on Main. StrawApp.globalScope.launch(Dispatchers.IO) { sp.edit().putString(KEY_POSITIONS, json.encodeToString(next)).apply() } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt index 56b0d3575..4b9473fe2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SearchCacheStore.kt @@ -12,7 +12,7 @@ * = ~150 KB worst case. * * Backed by a MutableStateFlow loaded once at construction — - * record()/load() are atomic against concurrent calls. vc=36 audit + * record/load are atomic against concurrent calls. audit * B5: the prior load()→edit()→write() pattern would clobber a * concurrent record() with whichever happened to persist last. * diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index bda38ba0a..7ded93aba 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -72,7 +72,7 @@ enum class AutoUpdateInterval(val label: String) { /** * User-facing cache caps. Each store's hard limit is the cap's value; * `Int.MAX_VALUE` means "unlimited" (the store grows without trimming). - * Defaults match the pre-vc=59 hardcoded constants so existing data + * Defaults match the earlier hardcoded constants so existing data * keeps the same shape until the user picks something different. */ enum class CacheCap(val label: String, val value: Int) { @@ -221,7 +221,7 @@ class SettingsStore(context: Context) { /** * Cached "latest version seen on fdroid" — 0 / "" while none known - * or while caught-up. Lets SettingsScreen show "vc=55 available" + * or while caught-up. Lets SettingsScreen show "an update available" * without re-polling. */ private val _latestKnownVc = MutableStateFlow( @@ -244,7 +244,7 @@ class SettingsStore(context: Context) { * "#shorts" / "#Shorts" / "(shorts)" which most short uploaders * include. * Filter is best-effort — a hand-tagged short with a clean title - * in the subs feed will slip through until vc=57 plumbs an + * in the subs feed will slip through until a future build plumbs an * isShort flag through strawcore-core. */ private val _hideShorts = MutableStateFlow( @@ -258,7 +258,7 @@ class SettingsStore(context: Context) { * takes effect immediately (next write trims to the new cap; reads * are unbounded since they're already in memory). * - * Defaults match the pre-vc=59 hardcoded constants so first-launch + * Defaults match the earlier hardcoded constants so first-launch * behavior is unchanged from prior versions. */ private val _historyWatchesCap = MutableStateFlow( @@ -308,10 +308,9 @@ class SettingsStore(context: Context) { } // Atomic + idempotent. Capture before-state, update in-memory, - // skip the SP write when the value didn't actually change. Round-5 - // audit LOW-1 / MED-2: the prior shape used - // `updateAndGet { r } == r` which is unconditionally true (lambda - // ignores prior) — dead code that confused readers. + // skip the SP write when the value didn't actually change. The + // prior shape used `updateAndGet { r } == r` which is unconditionally + // true (the lambda ignores prior) — dead code that confused readers. fun setMaxResolution(r: MaxResolution) { val before = _maxResolution.value if (before == r) return diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index 97a3bf994..f25a6bbc0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -66,7 +66,7 @@ class SubscriptionsStore(context: Context) { /** * Bulk-add. Single persist instead of N. Per-call `toggle()` was - * O(N²) + N SP writes, which the vc=34 security audit flagged as + * O(N²) + N SP writes, which the security audit flagged as * a DoS vector for hostile NewPipe-export imports. Single linear * scan to dedup, one persist regardless of input size. Returns the * count of NEW (not previously-subscribed) channels added so the @@ -76,8 +76,8 @@ class SubscriptionsStore(context: Context) { // Count NEW refs by checking each input URL against the // current state's pre-image inside the CAS lambda. Captures // exactly the additions this call made — concurrent - // toggle()s that race the CAS don't inflate the count (vc=37 - // round-3 audit HIGH-2/CVE-2). The counter lives in an + // toggles that race the CAS don't inflate the count ( + // ). The counter lives in an // AtomicInteger so each lambda re-run resets it correctly. val counter = java.util.concurrent.atomic.AtomicInteger(0) val next = _subs.updateAndGet { state -> diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 1b1ab29ce..c2ea93b5d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -80,7 +80,7 @@ fun ChannelScreen( // the screen recomposes once with A's state before vm.load(B) // resets it. Without this branch we'd render channel A's banner / // name / videos under URL B. Same shape as VideoDetailScreen's - // gate. Round-69 audit HIGH-1. + // gate. state.loadedUrl != channelUrl -> Box( modifier = Modifier.fillMaxSize().statusBarsPadding(), contentAlignment = Alignment.Center, @@ -218,8 +218,8 @@ private fun ChannelVideoRow( // Don't repeat duration here — VideoThumbnail's // bottom-right badge already shows it. Add the upload // date so the row reads 'N views · 2 days ago' the way - // YT renders it. vc=65 — Cobb caught the duplicate - // duration + missing date on the channel page. + // YT renders it. The earlier row was duplicating duration + // and missing the upload date on the channel page. val meta = buildString { if (item.viewCount > 0) append("${formatCount(item.viewCount)} views") if (item.uploadDateRelative.isNotBlank()) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index fa2dbd404..5415f2c95 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -36,7 +36,7 @@ data class ChannelUiState( * frame before vm.load(B) clears it. Without this field, any * caller that derives "this is the channel we want" from * `state.name` (or other display fields) is reading channel A's - * data while believing it's B. Round-68 audit MED-4. + * data while believing it's B. */ val loadedUrl: String? = null, ) @@ -47,20 +47,20 @@ class ChannelViewModel : ViewModel() { // Track the active load coroutine — same shape as // VideoDetailViewModel. Rapid channel switches no longer race; - // the late-arriving older fetch is cancelled. Round-4 audit - // HIGH-2 / MED-1. + // the late-arriving older fetch is cancelled. + // / MED-1. private var inFlight: Job? = null fun load(channelUrl: String) { - // Snapshot _ui once so the two reads agree. Round-68 audit MED-4. + // Snapshot _ui once so the two reads agree. val snap = _ui.value if (snap.loadedUrl == channelUrl && snap.videos.isNotEmpty()) return - // Round-5 audit MED-3: extractor-emitted uploaderUrl can be + // extractor-emitted uploaderUrl can be // attacker-controlled if the YT response is poisoned upstream. // Refuse non-YT hosts at the entry point so we don't even - // issue a network call to evil.com via strawcore. Round-6 - // audit HIGH-1: also cancel inFlight on rejection so a - // still-resolving prior load can't clobber the error banner. + // issue a network call to evil.com via strawcore. Also cancel + // inFlight on rejection so a still-resolving prior load can't + // clobber the error banner. if (!isAllowedYtUrl(channelUrl)) { inFlight?.cancel() inFlight = null @@ -110,8 +110,7 @@ class ChannelViewModel : ViewModel() { loading = false, // Scrub before storing — UniFFI/Rust exceptions // can embed full signed googlevideo URLs in the - // message (NetworkError::Recaptcha { url }). vc=37 - // round-3 audit CVE-1. + // message (NetworkError::Recaptcha { url }). error = com.sulkta.straw.util.LogDump.scrubLine( t.message ?: t.javaClass.simpleName, ), diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt index ed0c0e044..a4d7e4fc4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -103,8 +103,7 @@ object SettingsImport { private const val YT_SERVICE_ID = 0 // The allowlist itself lives in util.YtUrl now — VideoDetailViewModel - // also gates auto-channelInfo + recordWatch through it. Round-4 - // audit HIGH-4 / HIGH-5. + // also gates auto-channelInfo + recordWatch through it. private fun isAllowedYtUrl(url: String): Boolean = com.sulkta.straw.util.isAllowedYtUrl(url) @@ -113,7 +112,7 @@ object SettingsImport { // runInner is suspend (it switches to NonCancellable for // cleanup). Plain runCatching would swallow a user-back // CancellationException and surface it as a normal - // failure with a misleading banner. Round-6 audit HIGH-2. + // failure with a misleading banner. com.sulkta.straw.util.runCatchingCancellable { runInner(context, zipUri) } @@ -121,7 +120,7 @@ object SettingsImport { /** * Sweep stale import work-dirs left behind by a previous run that - * was killed mid-extraction. CRIT from the vc=34 security audit: + * was killed mid-extraction. CRIT from the security audit: * a force-killed import leaves the user's full newpipe.db sitting * in cacheDir indefinitely. StrawApp.onCreate calls this on every * cold start. @@ -203,7 +202,7 @@ object SettingsImport { // Reject duplicate entries — a malicious zip // can put a benign db first and a hostile // second; ZipInputStream walks in order and - // would overwrite. Round-6 audit MED-5. + // would overwrite. if (dbFile != null) { warnings += "duplicate newpipe.db in archive — aborting" return null to null @@ -322,8 +321,7 @@ object SettingsImport { openDb(dbFile).use { db -> val playlistRows = mutableListOf>() // Hard caps so a malicious export with millions of rows - // doesn't walk an unbounded cursor into memory. Round-6 - // audit MED-3. + // doesn't walk an unbounded cursor into memory. db.rawQuery("SELECT uid, name FROM playlists LIMIT 256", null).use { c -> while (c.moveToNext()) { val uid = c.getLong(0) @@ -362,7 +360,7 @@ object SettingsImport { // instead of (1 create + N addItem) writes. Old shape // produced ~10k SP commits on a 100×100 export, plus // O(N²) work in addItem's per-call linear scan over - // every playlist. Round-4 audit HIGH-2. + // every playlist. store.importPlaylist(name, items) playlistsAdded++ itemsAdded += items.size @@ -390,7 +388,7 @@ object SettingsImport { openDb(dbFile).use { db -> // Search history — feed oldest first so the store ends up with // the most-recent on top after its own dedup + take(MAX). - // Stage + bulk-write — vc=37 round-3 audit CVE MED-6: + // Stage + bulk-write —: // per-row recordSearch was N SP writes on potentially // 100k+ rows. The SELECT also lacked a LIMIT; added now. val stagedSearches = mutableListOf() @@ -458,7 +456,7 @@ object SettingsImport { // recordAllWatches / recordAllSearches return the real // added count (counts fresh videoIds / queries that landed, // ignoring duplicates and pre-saturated-store truncation). - // Round-4 audit HIGH-7 / MED-2 — previous size_after - + // / MED-2 — previous size_after // size_before reported 0 when the store was already at cap // even when 20 fresh imports actually landed. return HistResult( @@ -496,7 +494,6 @@ object SettingsImport { // changed something. Prior shape counted every // observed key, inflating the import summary to // "12 settings applied" when only 2 changed. - // Round-6 audit MED-2. if (want != have) { settings.toggle(cat) applied++ diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 87f9e2b3e..6c726aff2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -264,8 +264,8 @@ fun VideoDetailScreen( // vm.load(B)'s reset propagates. Without this gate, the // InlinePlayer's LaunchedEffect would fire with // streamUrl=B but resolved=A's URLs and play A under - // B's chrome (Cobb-reported 2026-05-26: detail page - // shows new video, audio is the old one). + // B's chrome — symptom is the detail page showing the + // new video while the audio is still the old one. if (state.loadedUrl != streamUrl) return@Column // Player surface — edge-to-edge, NewPipe/YouTube style. // Lives outside the 16dp horizontal padding so the @@ -485,7 +485,7 @@ fun VideoDetailScreen( } // PiP into nothing isn't useful — bail with a // Toast if there's no controller / no resolved - // playback to push into it. vc=34 audit Q-13. + // playback to push into it. val c = controller val r = state.resolved if (c == null || r == null) { @@ -715,7 +715,7 @@ private fun RelatedRow( // the uploader name on each row — it's implicit. Skip // empty pieces with the leading-separator dance so we // never end up with " · viewCount" or trailing dots. - // vc=64 — Cobb caught the empty metadata line on + // Earlier shape was leaving an empty metadata line on // More-from-channel rows. val meta = buildString { if (item.uploader.isNotBlank()) append(item.uploader) @@ -770,7 +770,7 @@ private fun InlinePlayer( // retryVersion lets the user manually re-fire setPlayingFrom after // a playback error. Without it, the screen used to lock into the // thumbnail+spinner branch once NowPlaying.clear() fired from - // onPlayerError. vc=62 audit BUG-2. + // onPlayerError. val resolved = state.resolved var retryVersion by remember(streamUrl) { mutableIntStateOf(0) } LaunchedEffect(controller, resolved, streamUrl, retryVersion) { @@ -794,12 +794,11 @@ private fun InlinePlayer( val listener = object : Player.Listener { override fun onPlayerError(error: androidx.media3.common.PlaybackException) { // Scrub the message — Media3's HttpDataSource exceptions - // include the full signed URL in .message. vc=36 audit - // CVE HIGH-1. + // include the full signed URL in.message. val raw = error.message ?: "(no message)" playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}" // Clear NowPlaying so the minibar drops the dead - // session. vc=36 audit MED-3. + // session. NowPlaying.clear() } } @@ -834,7 +833,7 @@ private fun InlinePlayer( Spacer(modifier = Modifier.height(12.dp)) OutlinedButton(onClick = { // Clear the error AND nudge the LaunchedEffect to - // re-attempt setPlayingFrom. vc=62 audit BUG-2 — + // re-attempt setPlayingFrom. // without this the screen used to lock on the // error forever after NowPlaying.clear(). playbackError = null diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index df48c4fb4..d6038f9b9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -100,7 +100,7 @@ data class VideoDetailUiState( * vm.load(B) clears it. Without this field, the InlinePlayer's * setPlayingFrom would fire with streamUrl=B but resolved=A's * playback URLs — claiming NowPlaying with B's streamUrl but - * playing A's video under it. vc=63 audit. + * playing A's video under it. audit. */ val loadedUrl: String? = null, ) @@ -112,7 +112,7 @@ class VideoDetailViewModel : ViewModel() { // Track the active load coroutine so a rapid tap to a different video // cancels the prior fetch; otherwise a slow-to-finish older load // overwrites the newer state and the player ends up streaming A while - // the detail UI shows B. Round-4 audit HIGH-2. + // the detail UI shows B. private var inFlight: Job? = null fun load(streamUrl: String) { @@ -123,11 +123,11 @@ class VideoDetailViewModel : ViewModel() { if (snap.loadedUrl == streamUrl && snap.detail != null) return // Same YT-host gate as ChannelViewModel — covers the case // where a tap on a poisoned related-card lands here. - // Round-5 audit MED-3. Round-6 audit HIGH-1: cancel any + // cancel any // in-flight load on rejection too — otherwise the // late-arriving prior-job's fence still PASSES (loadedUrl // wasn't moved) and clobbers the "Unsupported URL" error - // banner. round-67 audit HIGH-7: also set loadedUrl on this + // banner.: also set loadedUrl on this // path so the gate reads coherently for any caller that // checks _ui.value.loadedUrl on the rejected path. if (!isAllowedYtUrl(streamUrl)) { @@ -155,12 +155,11 @@ class VideoDetailViewModel : ViewModel() { // Move SP write off the main coroutine — recordWatch // JSON-encodes the watch list (up to 50 entries) + - // sp.edit().apply(). Small but synchronous; vc=36 + // sp.edit.apply. Small but synchronous; // audit Q9. Only record when the resolved URL passes // the YT allowlist — otherwise extractor-emitted // non-YT URLs (poisoned related/moreFromChannel) end // up in Recent Watches and survive process death. - // Round-4 audit HIGH-5. if (isAllowedYtUrl(streamUrl)) { withContext(Dispatchers.IO) { runCatchingCancellable { @@ -216,9 +215,9 @@ class VideoDetailViewModel : ViewModel() { // Gate the auto-fetch behind the same YT-host allowlist // we apply to imports: a poisoned uploaderUrl from the // extractor would otherwise trigger an arbitrary-host - // network call. Round-4 audit HIGH-4. + // network call. // - // Round-69 audit MED-3: validate once and persist the + // validate once and persist the // SAFE value into VideoDetail.uploaderUrl so downstream // consumers (NowPlaying → PlaybackService autoplay, // queue, etc.) inherit the validated string instead @@ -246,7 +245,7 @@ class VideoDetailViewModel : ViewModel() { // extractor surfaces the URL string verbatim // and a poisoned channel page could ship // `data:image/svg+xml,...