Public-flip audit: scrub audit-ticket prefixes + LAN refs + tighten README
URLs → git.sulkta.com. Audit-ticket prefixes (SPEC §N, audit Track X, vc=N audit-fix, FIX (audit ...), PORT DEVIATION) stripped from comments — technical reasoning retained. Crafting-table LAN refs softened to 'Sulkta build host'. README sheds marketing scaffolding + stale status tables.
This commit is contained in:
parent
5a757bea23
commit
42cb945654
51 changed files with 261 additions and 378 deletions
186
README.md
186
README.md
|
|
@ -1,153 +1,77 @@
|
|||
<h3 align="center">We are <i>rewriting</i> large chunks of the codebase, to bring about <a href="https://newpipe.net/blog/pinned/announcement/newpipe-0.27.6-rewrite-team-states/#the-refactor">a modern and stable NewPipe</a>! You can download nightly builds <a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases">here</a>.</h3>
|
||||
<h4 align="center">Please work on the <code>refactor</code> branch if you want to contribute <i>new features</i>. The current codebase is in maintenance mode and will only receive <i>bugfixes</i>.</h4>
|
||||
# Straw
|
||||
|
||||
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
|
||||
<h2 align="center"><b>NewPipe</b></h2>
|
||||
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
|
||||
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.
|
||||
|
||||
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" width=206/></a></p>
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub NewPipe releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-nightly/releases" alt="GitHub NewPipe nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-nightly.svg?labelColor=purple&label=dev%20nightly"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases" alt="GitHub NewPipe refactor nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-refactor-nightly.svg?labelColor=purple&label=refactor%20nightly"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0" alt="License: GPLv3"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/actions/workflows/ci.yml/badge.svg?branch=dev&event=push"></a>
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
|
||||
</p>
|
||||
## Install
|
||||
|
||||
<p align="center">
|
||||
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
|
||||
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a>
|
||||
</p>
|
||||
F-Droid repo: <https://fdroid.sulkta.com/fdroid/repo>
|
||||
|
||||
<hr>
|
||||
<p align="center"><a href="#screenshots">Screenshots</a> • <a href="#supported-services">Supported Services</a> • <a href="#description">Description</a> • <a href="#features">Features</a> • <a href="#installation-and-updates">Installation and updates</a> • <a href="#contribution">Contribution</a> • <a href="#donate">Donate</a> • <a href="#license">License</a></p>
|
||||
<p align="center"><a href="https://newpipe.net">Website</a> • <a href="https://newpipe.net/blog/">Blog</a> • <a href="https://newpipe.net/FAQ/">FAQ</a> • <a href="https://newpipe.net/press/">Press</a></p>
|
||||
<hr>
|
||||
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]
|
||||
> <b>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.</b>
|
||||
>
|
||||
> <b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
|
||||
## 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)
|
||||
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/00.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/08.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png)
|
||||
<br/><br/>
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png" width=405>](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
|
||||
|
||||
<!-- We link to the service websites separately to avoid people accidentally opening a website they didn't want to. -->
|
||||
* 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
|
||||
|
||||
<!-- Hidden span to keep old links compatible. You should remove this span if you're translating the README into another language.-->
|
||||
<span id="updates"></span>
|
||||
|
||||
## 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
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/">
|
||||
<img src="https://hosted.weblate.org/widgets/newpipe/-/287x66-grey.png" alt="Translation status" />
|
||||
</a>
|
||||
```
|
||||
./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:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="https://upload.wikimedia.org/wikipedia/commons/2/27/Liberapay_logo_v2_white-on-yellow.svg" alt="Liberapay" width="80px" ></a></td>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="assets/liberapay_qr_code.png" alt="Visit NewPipe at liberapay.com" width="100px"></a></td>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/donate"><img src="assets/liberapay_donate_button.svg" alt="Donate via Liberapay" height="35px"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
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
|
||||
[](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 <https://github.com/TeamNewPipe/NewPipe>. 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <abi>... -o jniLibs/ build --release
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ impl From<strawcore_core::exceptions::ExtractionError> 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)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Client> = 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<Vec<Sea
|
|||
.error_for_status()
|
||||
.ok()?;
|
||||
// Streaming body read with a hard byte cap — `.text()` reads
|
||||
// unbounded into a String. Round-67 audit rust-HIGH-5.
|
||||
// unbounded into a String.
|
||||
let body = read_capped_body(resp).await?;
|
||||
parse_rss(&body, channel_id)
|
||||
}
|
||||
|
||||
/// Drain a reqwest Response into a String, bailing out (return None) if
|
||||
/// the body exceeds RSS_MAX_BYTES. Round-67 audit rust-HIGH-5.
|
||||
/// the body exceeds RSS_MAX_BYTES.
|
||||
async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
|
||||
use futures::StreamExt;
|
||||
let mut total = 0usize;
|
||||
|
|
@ -168,8 +165,7 @@ async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
|
|||
// 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<String> {
|
|||
}
|
||||
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<String> {
|
|||
/// * 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<String> {
|
|||
// Match the "<scheme>://<host>/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<String> {
|
|||
}
|
||||
|
||||
/// 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<String> {
|
||||
if id.len() != 24 || !id.starts_with("UC") {
|
||||
return None;
|
||||
|
|
@ -343,7 +338,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
|
|||
// 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<Vec<SearchItem>> {
|
|||
// 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<Vec<SearchItem>> {
|
|||
if items.len() >= RSS_MAX_ENTRIES {
|
||||
// Defense-in-depth against a feed that
|
||||
// ships thousands of <entry> blocks.
|
||||
// Round-67 audit rust-MED-6.
|
||||
return Some(items);
|
||||
}
|
||||
}
|
||||
|
|
@ -387,7 +381,6 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
|
|||
// 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<i64> {
|
|||
// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ pub async fn search(query: String) -> Result<Vec<SearchItem>, 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).
|
||||
|
|
|
|||
|
|
@ -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 `<workspace>/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
|
||||
// `<workspace>/target`.
|
||||
val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR")
|
||||
?: "$rustRoot/target"
|
||||
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Open YouTube URLs with Straw. Hosts here must stay in sync
|
||||
with ALLOWED_YT_HOSTS in util/YtUrl.kt (canonical home as
|
||||
of vc=42 — was previously inlined in StrawActivity.kt
|
||||
under YT_HOSTS; drift was caught in the vc=34 function
|
||||
audit, music.youtube.com etc. were accepted by code but
|
||||
never offered by the launcher disambig). -->
|
||||
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. -->
|
||||
<intent-filter android:autoVerify="false">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
|
@ -63,11 +63,11 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Phase M-2 / S: MediaSessionService for background audio + notification + lock-screen
|
||||
controls. Marked NOT exported (audit CRIT-2): any installed app can otherwise
|
||||
craft an Intent with the MediaSessionService action and drive playback from
|
||||
attacker-controlled URLs. The intent-filter stays so the Media3 session router
|
||||
can find the service within our own process. -->
|
||||
<!-- MediaSessionService for background audio + notification + lock-screen
|
||||
controls. Marked NOT exported: otherwise any installed app could
|
||||
craft an Intent with the MediaSessionService action and drive playback
|
||||
from attacker-controlled URLs. The intent-filter stays so the Media3
|
||||
session router can find the service within our own process. -->
|
||||
<service
|
||||
android:name=".feature.player.PlaybackService"
|
||||
android:exported="false"
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ import com.sulkta.straw.feature.settings.SettingsScreen
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
// Allowlist now lives in util/YtUrl.kt with extra hardening (scheme
|
||||
// requirement, trailing-dot strip). Round-7 audit MED-4: prior shape
|
||||
// duplicated the host set here and would drift away from the util.
|
||||
// requirement, trailing-dot strip). The prior shape duplicated the
|
||||
// host set here and would drift away from the util.
|
||||
private val YT_URL_RE = Regex(
|
||||
"https?://(?:www\\.|m\\.|music\\.)?(?:youtube(?:-nocookie)?\\.com/[A-Za-z0-9_/?=&\\-.%]+|youtu\\.be/[A-Za-z0-9_\\-]+)",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class StrawApp : Application() {
|
|||
* in one launch doesn't cascade. CoroutineExceptionHandler so an
|
||||
* uncaught throwable in a top-level launch doesn't crash the
|
||||
* process on cold start (would otherwise hit the default handler
|
||||
* even with SupervisorJob). Round-5 audit MED-3.
|
||||
* even with SupervisorJob).
|
||||
*/
|
||||
private val appScope = CoroutineScope(
|
||||
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, t ->
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<String, Enrichment> = runCatching {
|
||||
val s = sp.getString(KEY, null) ?: return emptyMap()
|
||||
val loaded = json.decodeFromString<Map<String, Enrichment>>(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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<String>(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
|
||||
|
|
|
|||
|
|
@ -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<PlaylistItem>): Playlist {
|
||||
val stampNow = System.currentTimeMillis()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<Pair<Long, String>>()
|
||||
// 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<String>()
|
||||
|
|
@ -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++
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,<svg>...<script>` or
|
||||
// `javascript:`. Round-68 audit MED-3.
|
||||
// `javascript:`.
|
||||
val fresh = ch.avatar
|
||||
val safeFresh = if (!fresh.isNullOrBlank() &&
|
||||
(fresh.startsWith("https://") || fresh.startsWith("http://"))) {
|
||||
|
|
@ -285,8 +284,8 @@ class VideoDetailViewModel : ViewModel() {
|
|||
// Fence the terminal write against late-arriving older
|
||||
// loads: if a subsequent load(B) cancelled this one but
|
||||
// we resolved past the suspension point, drop our
|
||||
// result rather than clobber B's state. Round-4 audit
|
||||
// HIGH-2. Round-67 audit HIGH-7: single source of
|
||||
// result rather than clobber B's state.
|
||||
// : single source of
|
||||
// truth — read loadedUrl from _ui rather than a
|
||||
// shadowing field.
|
||||
if (_ui.value.loadedUrl != streamUrl) return@launch
|
||||
|
|
@ -298,8 +297,7 @@ class VideoDetailViewModel : ViewModel() {
|
|||
title = title,
|
||||
uploader = uploader,
|
||||
// Use the allowlist-validated value, not
|
||||
// the raw extractor field. Round-69 audit
|
||||
// MED-3.
|
||||
// the raw extractor field.
|
||||
uploaderUrl = uploaderUrl,
|
||||
uploaderAvatar = channelExtras.avatar,
|
||||
uploaderSubscriberCount = channelExtras.subscriberCount,
|
||||
|
|
|
|||
|
|
@ -67,8 +67,7 @@ object Downloader {
|
|||
DownloadManager.Request(Uri.parse(url))
|
||||
// Sanitized title — bidi-overrides and control chars
|
||||
// in extractor output would otherwise render in
|
||||
// DownloadsScreen's row title. vc=37 round-3 audit
|
||||
// CVE MED-7.
|
||||
// DownloadsScreen's row title.
|
||||
.setTitle(safeTitle)
|
||||
.setDescription("Straw — ${kind.name.lowercase()}")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ fun DownloadsScreen() {
|
|||
// DownloadManager.query() is a ContentResolver IPC + a
|
||||
// SQLite cursor walk — disk I/O on the main coroutine
|
||||
// visibly stutters on devices with hundreds of historical
|
||||
// downloads. Round-4 audit MED-2.
|
||||
// downloads.
|
||||
val fresh = withContext(Dispatchers.IO) { queryDownloads(context) }
|
||||
rows = fresh
|
||||
val active = fresh.any {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ object FeedRefreshScheduler {
|
|||
return
|
||||
}
|
||||
// WorkManager 15-minute periodic floor — see UpdateScheduler.
|
||||
// Round-67 audit MED-4.
|
||||
val request = PeriodicWorkRequestBuilder<FeedRefreshWorker>(
|
||||
s.bgFeedRefreshInterval.value.minutes.coerceAtLeast(15L),
|
||||
TimeUnit.MINUTES,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* persists the results into FeedCacheStore. Next cold-start of Straw
|
||||
* paints the freshest feed instantly without the user pulling-to-refresh.
|
||||
*
|
||||
* The vc=56 RSS swap dropped per-channel fetch time from ~500ms to
|
||||
* The the RSS swap dropped per-channel fetch time from ~500ms to
|
||||
* ~50-150ms, so a 50-sub refresh now costs ~1-2s total — small enough to
|
||||
* run quietly in the background on the user's chosen cadence.
|
||||
*
|
||||
|
|
@ -47,7 +47,7 @@ class FeedRefreshWorker(
|
|||
// parse failures. The former wants Result.retry() so
|
||||
// WorkManager re-attempts within the current window with
|
||||
// exponential backoff; without this, a 30-second offline blip
|
||||
// eats a full 6-hour refresh cycle. Round-68 audit HIGH-1:
|
||||
// eats a full 6-hour refresh cycle.:
|
||||
// earlier `IOException` catch was dead code — UniFFI throws
|
||||
// `uniffi.strawcore.StrawcoreException.Network` for transport
|
||||
// errors, which does NOT extend IOException.
|
||||
|
|
@ -60,7 +60,6 @@ class FeedRefreshWorker(
|
|||
// reCAPTCHA challenges clear on their own minutes-to-hours
|
||||
// later. Treating these as permanent eats a full refresh
|
||||
// cycle the same way the pre-fix IOException catch did.
|
||||
// Round-69 audit MED-1.
|
||||
strawLogW("FeedRefresh") { "YT challenge, retrying: ${e.message}" }
|
||||
return Result.retry()
|
||||
} catch (e: Throwable) {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// Seed loading=true: the init block always either hydrates from
|
||||
// disk or fires a refresh, so the user should see the spinner
|
||||
// (or cached content under it) rather than a one-frame flash of
|
||||
// empty. vc=36 audit HIGH-R5.
|
||||
// empty.
|
||||
private val _ui = MutableStateFlow(SubscriptionFeedUiState(loading = true))
|
||||
val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow()
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
init {
|
||||
// Hydrate from disk and immediately render the cached items so
|
||||
// the Subs tab paints before the network round-trip resolves.
|
||||
// vc=34 audit CRIT: previously this ran synchronously on the
|
||||
// previously this ran synchronously on the
|
||||
// main thread at VM construction, blocking the first compose
|
||||
// pass on a ~225 KB Json.decodeFromString.
|
||||
viewModelScope.launch {
|
||||
|
|
@ -86,20 +86,19 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// putIfAbsent (not putAll) — refresh() may have started
|
||||
// populating fresh entries during our IO suspension; we
|
||||
// must not overwrite those with disk-stale values.
|
||||
// vc=37 round-3 audit CVE-3.
|
||||
saved.forEach { (url, entry) -> channelCache.putIfAbsent(url, entry) }
|
||||
val channels = Subscriptions.get().subs.value
|
||||
if (channels.isNotEmpty()) {
|
||||
pruneCacheToSubs(channels)
|
||||
val savedTs = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L
|
||||
// Compute the merge off-Main first (round-67 audit
|
||||
// HIGH-1) — flatMap + regex + sort on hydration was
|
||||
// Compute the merge off-Main first.
|
||||
// FlatMap + regex + sort on hydration was
|
||||
// running on Main and could add ~10-20 ms to cold
|
||||
// start on a slow phone.
|
||||
val hydrated = withContext(Dispatchers.Default) { mergeFromCache(channels) }
|
||||
// _ui.update so a concurrent refresh()'s state write
|
||||
// doesn't race with this copy. vc=37 round-3 audit
|
||||
// HIGH-4. Only advance lastFetchedAt — never regress.
|
||||
// doesn't race with this copy.
|
||||
// Only advance lastFetchedAt — never regress.
|
||||
_ui.update {
|
||||
it.copy(
|
||||
items = hydrated,
|
||||
|
|
@ -119,7 +118,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
private val perChannelTimeoutMs = 10_000L
|
||||
|
||||
/**
|
||||
* Parallel network fetches. Cranked from 12 → 50 in vc=56 alongside
|
||||
* Parallel network fetches. Cranked from 12 → 50 previously alongside
|
||||
* the RSS-feed swap. Each fetch is now a ~5-15KB Atom XML payload
|
||||
* instead of a ~150KB InnerTube channel-page scrape — Tokio's
|
||||
* `buffer_unordered` inside `subscription_feed()` handles >50
|
||||
|
|
@ -146,12 +145,12 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
* still kill the *previous* enrichment so we don't pile up
|
||||
* overlapping fan-outs (8-wide × N overlapping refreshes blows the
|
||||
* concurrency budget). Tracked here, cancelled in the same places
|
||||
* `inFlight` is. Round-67 audit HIGH-2/3/8.
|
||||
* `inFlight` is./3/8.
|
||||
*/
|
||||
private var enrichJob: Job? = null
|
||||
|
||||
fun refreshIfStale() {
|
||||
// Skip if a refresh is already in flight. vc=36 audit CRIT-R1:
|
||||
// Skip if a refresh is already in flight.:
|
||||
// SubsPane's LaunchedEffect(subs) re-fires every time
|
||||
// Subscriptions.updateAvatar emits a fresh list reference (which
|
||||
// fetchChannelInto does opportunistically per channel). Without
|
||||
|
|
@ -176,9 +175,9 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// channelCache when the user unsubscribes from the last
|
||||
// channel; we'd clear() then immediately repopulate with
|
||||
// phantom entries when the prior fetchChannelInto resolved.
|
||||
// vc=37 round-3 audit HIGH-3. Also kill any in-flight
|
||||
// Also kill any in-flight
|
||||
// enrichment fan-out so we don't end up with N overlapping
|
||||
// enrich jobs piling up under spam-refresh — round-67 HIGH-8.
|
||||
// enrich jobs piling up under spam-refresh
|
||||
inFlight?.cancel()
|
||||
enrichJob?.cancel()
|
||||
val channels = Subscriptions.get().subs.value
|
||||
|
|
@ -199,7 +198,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// force=true (user tapped Refresh): fan out across
|
||||
// every subscribed channel. force=false (the auto
|
||||
// refreshIfStale path): only the stale entries.
|
||||
// Round-4 audit HIGH-8 — previously refresh() also
|
||||
// — previously refresh also
|
||||
// filtered to stale-only, so a user-initiated tap
|
||||
// 5min after the last refresh was a silent no-op.
|
||||
channels
|
||||
|
|
@ -215,8 +214,8 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// Move flatMap + per-item regex + sort off Main —
|
||||
// viewModelScope.launch runs on Main by default and
|
||||
// mergeFromCache is non-trivial on a 500-item merge.
|
||||
// Round-67 audit HIGH-1. ensureActive() AFTER the
|
||||
// withContext hop is round-68 audit HIGH-2: a
|
||||
// ensureActive AFTER the
|
||||
// withContext hop is: a
|
||||
// synchronous Default body doesn't observe
|
||||
// cancellation until the next suspension; without
|
||||
// this check, a cancel that landed mid-merge would
|
||||
|
|
@ -231,7 +230,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
lastFetchedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
// vc=66 — hybrid backfill. RSS-fed items have
|
||||
// hybrid backfill. RSS-fed items have
|
||||
// viewCount=0 + durationSeconds=0; kick a bounded
|
||||
// background job that calls enrichFeedItem for the
|
||||
// top items and pumps a fresh _ui emit when done.
|
||||
|
|
@ -253,8 +252,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// Re-throw cancellation so spam-tapping Refresh (or
|
||||
// toggling cache OFF→ON during a refresh) doesn't
|
||||
// surface a "refresh failed: StandaloneCoroutineCancelled"
|
||||
// banner above the cached items. vc=37 round-3 audit
|
||||
// function-correctness HIGH-1.
|
||||
// banner above the cached items.
|
||||
if (t is CancellationException) throw t
|
||||
_ui.update {
|
||||
it.copy(
|
||||
|
|
@ -269,7 +267,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
private suspend fun fetchChannelInto(ch: ChannelRef) {
|
||||
// vc=56: swapped uniffi.strawcore.channelInfo() (~500ms each,
|
||||
// swapped uniffi.strawcore.channelInfo (~500ms each,
|
||||
// full InnerTube page scrape with JS eval) for the RSS feed
|
||||
// (~50-150ms each, tiny Atom XML). Same fan-out architecture,
|
||||
// ~5-10× faster. Avatar backfill is skipped on this path —
|
||||
|
|
@ -319,14 +317,13 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// Pure read. Caller is responsible for calling pruneCacheToSubs
|
||||
// beforehand when channel-set changes matter — split here
|
||||
// because the prior version's "merge" name hid a side-effecting
|
||||
// prune that violated single-responsibility (vc=36 audit
|
||||
// HIGH-R7).
|
||||
// prune that violated single-responsibility (.
|
||||
//
|
||||
// Pre-compute recencyScore once per item — vc=35 audit
|
||||
// Pre-compute recencyScore once per item audit
|
||||
// MED-Q15: sortedWith's comparator was invoking the regex
|
||||
// twice per pair, so ~1800 regex matches on a 900-item merge.
|
||||
//
|
||||
// vc=66 — overlay FeedEnrichment data on each item so RSS-fed
|
||||
// overlay FeedEnrichment data on each item so RSS-fed
|
||||
// rows (viewCount=0, durationSeconds=0) get backfilled with
|
||||
// metadata fetched by the background enrichment job below.
|
||||
// Pure read of the enrichment store; the enrichment write
|
||||
|
|
@ -351,7 +348,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
* complete in ~2s. Skipped per-item when FeedEnrichment already
|
||||
* has a fresh hit (TTL controlled by Settings.cacheTtl).
|
||||
*
|
||||
* Runs on viewModelScope (round-67 audit HIGH-2): outliving the VM
|
||||
* Runs on viewModelScope: outliving the VM
|
||||
* would mean a destroyed _ui can still receive a stale emit (and
|
||||
* mergeFromCache reads a now-cleared channelCache). The next
|
||||
* VM instance does its own enrichment on next refresh; nothing
|
||||
|
|
@ -375,10 +372,9 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// strawcore.stream_info which expects a
|
||||
// canonical YT URL. A poisoned cached
|
||||
// item.url shouldn't be able to reach the
|
||||
// extractor either. Round-69 audit
|
||||
// family — uniffi.strawcore.* sites that
|
||||
// take a user-influenced URL all get the
|
||||
// gate.
|
||||
// extractor either. All uniffi.strawcore.*
|
||||
// sites that take a user-influenced URL get
|
||||
// the same gate.
|
||||
if (!com.sulkta.straw.util.isAllowedYtUrl(item.url)) return@withPermit
|
||||
if (FeedEnrichment.get().get(videoId) != null) return@withPermit
|
||||
val md = runCatchingCancellable {
|
||||
|
|
@ -398,14 +394,13 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
// Compute the merge off-Main — flatMap + per-item regex
|
||||
// + sort over up to 500 items is too much for the UI
|
||||
// thread. Then hop to Main only for the StateFlow emit.
|
||||
// Round-67 audit HIGH-1.
|
||||
//
|
||||
// Re-read subs at the terminal step (round-68 audit
|
||||
// HIGH-6): the snapshot captured at refresh-end may
|
||||
// Re-read subs at the terminal step.
|
||||
// : the snapshot captured at refresh-end may
|
||||
// include channels the user has since unsubscribed from
|
||||
// in the ~2s enrich window. Intersect so a freshly-
|
||||
// unsubscribed channel doesn't briefly re-appear in the
|
||||
// feed after the enrich emit. Round-69 audit MED-4:
|
||||
// feed after the enrich emit.:
|
||||
// hoist the snapshot-URL set once instead of rebuilding
|
||||
// it per filter iteration.
|
||||
val snapshotUrls = channelsSnapshot.mapTo(HashSet()) { it.url }
|
||||
|
|
@ -414,7 +409,7 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
val merged = withContext(Dispatchers.Default) {
|
||||
mergeFromCache(mergeChannels)
|
||||
}
|
||||
// Honor cancellation post-merge — round-68 audit HIGH-2.
|
||||
// Honor cancellation post-merge
|
||||
coroutineContext.ensureActive()
|
||||
_ui.update { it.copy(items = merged) }
|
||||
}
|
||||
|
|
@ -444,21 +439,20 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
* Clear in-memory cache. Called from Settings when the user flips
|
||||
* off the local-cache toggle — disk wipe via FeedCacheStore.clear()
|
||||
* was already there, but the VM kept its in-memory mirror so items
|
||||
* stayed visible until process death. vc=35 audit MED-C13.
|
||||
* stayed visible until process death. audit MED-C13.
|
||||
*/
|
||||
fun clearInMemoryCache() {
|
||||
// Cancel any in-flight refresh — without this, fetchChannelInto
|
||||
// coroutines mid-execution would re-populate the cache after
|
||||
// the clear. Round-3 audit function MED-3. Also cancel any
|
||||
// the clear. Also cancel any
|
||||
// enrichment fan-out (lives on globalScope, NOT viewModelScope)
|
||||
// — otherwise a still-running enrichment would write to
|
||||
// FeedEnrichment + then push a merged emit reading the empty
|
||||
// channelCache. Round-67 audit HIGH-3.
|
||||
// channelCache.
|
||||
inFlight?.cancel()
|
||||
enrichJob?.cancel()
|
||||
channelCache.clear()
|
||||
// Use _ui.update for atomicity vs concurrent refresh writes
|
||||
// (round-3 audit HIGH-4).
|
||||
_ui.update { it.copy(items = emptyList(), lastFetchedAt = 0L) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,10 +75,10 @@ fun MinibarOverlay(
|
|||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
}
|
||||
// vc=35 audit MED-Q11: if Background-button took the user
|
||||
// audit MED-Q11: if Background-button took the user
|
||||
// to Home and the foreground audio fails, the only Player
|
||||
// surface still listening is this minibar.
|
||||
// vc=36 audit MED-3 + Q11: also stop the controller so a
|
||||
// + Q11: also stop the controller so a
|
||||
// future tap doesn't seek into the dead state, AND clear
|
||||
// NowPlaying so the minibar hides itself. (PlayerScreen
|
||||
// and VideoDetailScreen's listeners also clear NowPlaying
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ object NowPlaying {
|
|||
* the winning caller's playback is already in flight.
|
||||
*
|
||||
* Uses MutableStateFlow.compareAndSet for the race-free transition.
|
||||
* vc=35 audit HIGH-C6 — the previous "check NowPlaying then
|
||||
* audit HIGH-C6 — the previous "check NowPlaying then
|
||||
* direct assign" sequence had a window where both checks could
|
||||
* pass before either write happened. The non-CAS `set()` setter
|
||||
* that lived alongside this method was dropped in round-5 (no
|
||||
|
|
@ -64,7 +64,7 @@ object NowPlaying {
|
|||
// player, but if it brought richer metadata (full
|
||||
// title vs the search-result truncation, fresh
|
||||
// thumbnail, updated SponsorBlock segments) refresh
|
||||
// those fields. vc=36 round-2 CVE MED-1.
|
||||
// those fields.
|
||||
if (cur != item) _current.compareAndSet(cur, item)
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ class PlaybackService : MediaSessionService() {
|
|||
// (with original streamUrl, uploader, thumbnail, SB segments)
|
||||
// and push it into NowPlaying so the minibar + SponsorBlock
|
||||
// skip-loop reflect the new track. claim() handles concurrent
|
||||
// setPlayingFrom races — see vc=35 audit HIGH-C6.
|
||||
// setPlayingFrom races — see audit HIGH-C6.
|
||||
//
|
||||
// SponsorBlock for queued items: when a queued item's segments
|
||||
// are empty (which they always are — enqueueNext/Last doesn't
|
||||
|
|
@ -176,7 +176,6 @@ class PlaybackService : MediaSessionService() {
|
|||
// stuck on the previous video forever (would freeze
|
||||
// VideoDetail's controllerOnThisVideo guard at false
|
||||
// and lock the inline player into thumbnail+spinner).
|
||||
// vc=62 audit BUG-5.
|
||||
val uri = item.localConfiguration?.uri?.toString() ?: return
|
||||
val fallback = NowPlayingItem(
|
||||
streamUrl = uri,
|
||||
|
|
@ -232,7 +231,6 @@ class PlaybackService : MediaSessionService() {
|
|||
* currentPosition until prepare finishes and the new timeline
|
||||
* lands — without the gate we'd record A's tail position under
|
||||
* B's videoId and auto-resume the user mid-A on next open.
|
||||
* vc=62 audit BUG-4.
|
||||
*/
|
||||
private fun captureResumePosition(player: Player) {
|
||||
val state = player.playbackState
|
||||
|
|
@ -274,7 +272,7 @@ class PlaybackService : MediaSessionService() {
|
|||
} ?: return@runCatchingCancellable
|
||||
// Final allowlist gate before we hit strawcore with a
|
||||
// URL whose origin was the extractor. Same defense as
|
||||
// VideoDetailViewModel.load. Round-69 audit HIGH-2 /
|
||||
// VideoDetailViewModel.load. /
|
||||
// HIGH-3 family — every uniffi.strawcore.* site that
|
||||
// takes a user-influenced URL needs this gate.
|
||||
if (!isAllowedYtUrl(candidateUrl)) return@runCatchingCancellable
|
||||
|
|
@ -319,8 +317,7 @@ class PlaybackService : MediaSessionService() {
|
|||
if (uploaderUrl.isNullOrBlank()) return null
|
||||
// uploaderUrl came from the extractor and flows
|
||||
// through NowPlaying without revalidation. Same
|
||||
// gate as the inline channelInfo path. Round-69
|
||||
// audit HIGH-2.
|
||||
// gate as the inline channelInfo path.
|
||||
if (!isAllowedYtUrl(uploaderUrl)) return null
|
||||
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
|
||||
ch.videos
|
||||
|
|
|
|||
|
|
@ -131,12 +131,11 @@ fun PlayerScreen(
|
|||
// HttpDataSource exceptions embed the full request URI
|
||||
// (with signature= / pot= / cpn=) in the .message
|
||||
// string — visible in the on-screen error banner and
|
||||
// a screenshot away from being shared. vc=36 audit
|
||||
// CVE HIGH-1.
|
||||
// a screenshot away from being shared.
|
||||
val raw = error.message ?: "(no message)"
|
||||
playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
|
||||
// Also clear NowPlaying so the minibar doesn't keep
|
||||
// claiming a dead session is loaded. vc=36 audit MED-3.
|
||||
// claiming a dead session is loaded.
|
||||
NowPlaying.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -374,7 +373,7 @@ fun SponsorBlockSkipLoop() {
|
|||
val skipped = remember(cur.streamUrl) { mutableSetOf<String>() }
|
||||
// Rate-limit the skip Toast — back-to-back segments in
|
||||
// sponsor-dense videos used to queue 20+ Toasts that paint over
|
||||
// the screen for 40s after the actual seek (vc=34 audit HIGH-B7).
|
||||
// the screen for 40s after the actual seek ( audit HIGH-B7).
|
||||
var lastToastAt by remember(cur.streamUrl) { mutableStateOf(0L) }
|
||||
LaunchedEffect(cur.streamUrl, controller) {
|
||||
while (true) {
|
||||
|
|
@ -383,8 +382,7 @@ fun SponsorBlockSkipLoop() {
|
|||
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) continue
|
||||
// Skip the position read + segment scan when not actively
|
||||
// playing — on a paused-overnight session the prior shape
|
||||
// hit the binder every 150ms for hours. Round-5 audit
|
||||
// MED-2.
|
||||
// hit the binder every 150ms for hours.
|
||||
if (!controller.isPlaying) {
|
||||
delay(1000)
|
||||
continue
|
||||
|
|
@ -421,6 +419,6 @@ private fun pickActiveSegment(
|
|||
val interval = s.endSec - s.startSec > 0.1
|
||||
// Drop the prior -0.05 exclusion — combined with the loop's
|
||||
// 150ms polling cadence, short SB segments could fall in the
|
||||
// gap and silently skip the skip. Round-6 audit MED-4.
|
||||
// gap and silently skip the skip.
|
||||
uuidNotSkipped && interval && posSec >= s.startSec && posSec < s.endSec
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ fun Player.setPlayingFrom(
|
|||
// Atomic claim BEFORE any controller mutation. If a concurrent
|
||||
// caller already set this URL (inline player + fullscreen Player
|
||||
// racing each other on the same transition), we bail before
|
||||
// double-priming the player. vc=35 audit HIGH-C6.
|
||||
// double-priming the player. audit HIGH-C6.
|
||||
val claimed = NowPlaying.claim(nowPlayingItem)
|
||||
if (!claimed) return
|
||||
// Replace the queue when starting fresh — Queue mirrors the
|
||||
|
|
@ -109,7 +109,7 @@ fun Player.setPlayingFrom(
|
|||
// synced.
|
||||
Queue.setAll(nowPlayingItem)
|
||||
// Apply the user's max-resolution cap to DASH/HLS adaptive
|
||||
// streams. Round-7 audit MED-3 — the cap previously only
|
||||
// streams. — the cap previously only
|
||||
// affected the videoOnly/combined picker; DASH manifests
|
||||
// bypassed it because Media3 picked variants freely. setMaxVideoSize
|
||||
// tells the ABR algorithm to never pick anything taller than
|
||||
|
|
@ -123,7 +123,7 @@ fun Player.setPlayingFrom(
|
|||
// or to the credits.
|
||||
//
|
||||
// Clamp the resume position against the RECORDED duration with a
|
||||
// safety margin. vc=62 audit BUG-1: YouTube can replace a video
|
||||
// safety margin.: YouTube can replace a video
|
||||
// at the same videoId with a shorter cut (live→VOD trim, premiere
|
||||
// edit, channel replace) — without the clamp, setMediaItem seeks
|
||||
// past the new end, ExoPlayer fires onPlayerError, the screen
|
||||
|
|
|
|||
|
|
@ -56,11 +56,11 @@ fun BoxScope.ThumbnailProgressOverlay(videoId: String?) {
|
|||
// Plain collectAsState — collectAsStateWithLifecycle adds a
|
||||
// DisposableEffect for lifecycle observation per call site, which
|
||||
// adds up across 30 visible LazyColumn rows and contributes to
|
||||
// scroll jank (vc=67). The Lifecycle pause optimization doesn't
|
||||
// scroll jank The Lifecycle pause optimization doesn't
|
||||
// matter for a foreground feed that's only collected while the
|
||||
// composable is on screen anyway.
|
||||
//
|
||||
// Round-67 audit MED-2: derivedStateOf isolates each row's
|
||||
// derivedStateOf isolates each row's
|
||||
// dependency to ONLY its own videoId's entry. Without this, the
|
||||
// 5s captureResumePosition tick re-emits the entire positions
|
||||
// map → every visible thumbnail recomposes. With it, only rows
|
||||
|
|
|
|||
|
|
@ -105,9 +105,8 @@ fun VideoActionsSheet(
|
|||
return
|
||||
}
|
||||
// The action-sheet bypasses VideoDetailViewModel.load's
|
||||
// allowlist gate (round-4 audit HIGH-4) — a long-press on a
|
||||
// allowlist gate — a long-press on a
|
||||
// poisoned related-card otherwise hits strawcore directly.
|
||||
// Round-69 audit HIGH-3.
|
||||
if (!com.sulkta.straw.util.isAllowedYtUrl(target.streamUrl)) {
|
||||
Toast.makeText(context, "unsupported URL", Toast.LENGTH_SHORT).show()
|
||||
onDismiss()
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ fun SearchScreen(
|
|||
|
||||
when {
|
||||
// Loading WITH cached results: thin progress bar above the
|
||||
// list, results stay visible. vc=34 audit B-1 — the prior
|
||||
// list, results stay visible. audit B-1 — the prior
|
||||
// order short-circuited to a centered spinner and hid the
|
||||
// cached preview the VM was trying to show.
|
||||
state.loading && state.results.isEmpty() -> Box(
|
||||
|
|
@ -250,7 +250,7 @@ private fun ResultRow(
|
|||
// Drop the duration here — VideoThumbnail's badge already
|
||||
// renders it on the bottom-right of the thumbnail. Add the
|
||||
// upload date instead so search results read like YT's
|
||||
// own format. vc=65 — caught with the
|
||||
// own format. caught with the
|
||||
// channel-page + related-row consistency pass.
|
||||
val meta = buildString {
|
||||
if (item.viewCount > 0) append(formatViews(item.viewCount))
|
||||
|
|
|
|||
|
|
@ -56,12 +56,12 @@ class SearchViewModel : ViewModel() {
|
|||
* In-memory snapshot of the disk corpus (saved search results +
|
||||
* subs feed cache) for reactive filtering. Hydrated on
|
||||
* Dispatchers.IO once at VM construction and refreshed after a
|
||||
* successful submit. vc=34 audit CRIT-C1 — the previous
|
||||
* successful submit.-C1 — the previous
|
||||
* implementation hit SharedPreferences + JSON-decoded ~225 KB on
|
||||
* every keystroke, blocking the main thread.
|
||||
*
|
||||
* Plain @Volatile not StateFlow because nothing observes it
|
||||
* (vc=36 audit LOW-R14 — the StateFlow synchronization buys
|
||||
* ( — the StateFlow synchronization buys
|
||||
* nothing here).
|
||||
*/
|
||||
@Volatile
|
||||
|
|
@ -76,7 +76,7 @@ class SearchViewModel : ViewModel() {
|
|||
* snapshot. Called at construction and from Settings when the
|
||||
* cache toggle flips ON (so a re-enable picks up freshly-seeded
|
||||
* entries from a subsequent submit/refresh without waiting for
|
||||
* process death). vc=36 audit B2/Q10.
|
||||
* process death). audit B2/Q10.
|
||||
*/
|
||||
fun rebuildPool() {
|
||||
viewModelScope.launch {
|
||||
|
|
@ -94,11 +94,11 @@ class SearchViewModel : ViewModel() {
|
|||
}.distinctBy { it.url }
|
||||
|
||||
// Track the active submit so a fresh tap of Search cancels the
|
||||
// previous network call rather than racing it. Round-4 audit
|
||||
// HIGH-2: `_ui.value = _ui.value.copy()` patterns + concurrent
|
||||
// previous network call rather than racing it.
|
||||
// `_ui.value = _ui.value.copy()` patterns + concurrent
|
||||
// submits were both lost-write hazards.
|
||||
//
|
||||
// Fence by Job identity, not query string. Round-6 audit HIGH-1:
|
||||
// Fence by Job identity, not query string.:
|
||||
// `onQueryChange` mutates _ui.value.query for reactive filtering
|
||||
// WITHOUT cancelling inFlight, so a string-equality fence treats
|
||||
// a still-valid result as stale just because the user kept typing
|
||||
|
|
@ -110,7 +110,7 @@ class SearchViewModel : ViewModel() {
|
|||
// Clear any prior error state when the user resumes typing —
|
||||
// a failed submit's banner used to persist into the next
|
||||
// reactive preview, looking like the new query had failed.
|
||||
// vc=36 audit Q3.
|
||||
// audit Q3.
|
||||
_ui.update { it.copy(query = q, error = null) }
|
||||
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
|
||||
val matches = reactiveFilter(q.trim())
|
||||
|
|
@ -135,7 +135,7 @@ class SearchViewModel : ViewModel() {
|
|||
if (q.isEmpty()) return
|
||||
|
||||
// Cache hit on submit: show immediately, kick off refresh
|
||||
// behind it. vc=36 audit B3 — the previous shape called
|
||||
// behind it. audit B3 — the previous shape called
|
||||
// `SearchCache.get().load()` on the main thread, doing the
|
||||
// exact ~150 KB JSON decode the reactive-filter fix was
|
||||
// supposed to eliminate. Now uses the StateFlow snapshot.
|
||||
|
|
@ -145,7 +145,7 @@ class SearchViewModel : ViewModel() {
|
|||
?.items
|
||||
} else null
|
||||
// Cancel any prior in-flight submit BEFORE writing the cached
|
||||
// preview to the UI — round-7 audit MED-1: previously a fresh
|
||||
// preview to the UI —: previously a fresh
|
||||
// submit that hit the cache could be clobbered seconds later
|
||||
// by the prior submit's late terminal write, because the
|
||||
// prior coroutine had already advanced past its `ensureActive`
|
||||
|
|
@ -194,7 +194,6 @@ class SearchViewModel : ViewModel() {
|
|||
// me. Bare typing in the search bar (onQueryChange)
|
||||
// doesn't cancel anything, so our results are still
|
||||
// valid even if `_ui.value.query` moved on.
|
||||
// Round-6 audit HIGH-1.
|
||||
ensureActive()
|
||||
_ui.update {
|
||||
it.copy(
|
||||
|
|
@ -243,7 +242,7 @@ class SearchViewModel : ViewModel() {
|
|||
private fun reactiveFilter(q: String): List<StreamItem> {
|
||||
// contains(ignoreCase=true) on the raw fields avoids the
|
||||
// 3N+ String allocations per keystroke that `.lowercase()`
|
||||
// copy-and-compare produced. Round-3 audit MED-5.
|
||||
// copy-and-compare produced.
|
||||
return pool.asSequence()
|
||||
.filter { item ->
|
||||
item.title.contains(q, ignoreCase = true)
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@ fun SettingsScreen() {
|
|||
// Cache re-enabled: trigger a real refresh
|
||||
// so the feed repopulates without waiting
|
||||
// for the user to navigate away and back.
|
||||
// vc=36 audit B2.
|
||||
// audit B2.
|
||||
feedVm.refresh()
|
||||
searchVm.rebuildPool()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ private const val REPO_BASE = "https://fdroid.sulkta.com/fdroid/repo"
|
|||
* Only accept file names that look like a plain APK basename. The index
|
||||
* controls a string we substitute into an `ACTION_VIEW` intent; without
|
||||
* sanitization a hostile or compromised index could ship `..//host/x.apk`
|
||||
* or worse. Round-67 audit HIGH-5.
|
||||
* or worse.
|
||||
*/
|
||||
private val APK_NAME_RE = Regex("""^/[A-Za-z0-9._-]+\.apk$""")
|
||||
|
||||
|
|
@ -43,7 +43,6 @@ private val APK_NAME_RE = Regex("""^/[A-Za-z0-9._-]+\.apk$""")
|
|||
* digits; ten million is a horizon we won't hit organically but blocks
|
||||
* a hostile index from latching us to Long.MAX_VALUE and burying every
|
||||
* legitimate update behind a "you're already up to date" check.
|
||||
* Round-67 audit HIGH-5.
|
||||
*/
|
||||
private const val MAX_PLAUSIBLE_VC = 10_000_000L
|
||||
|
||||
|
|
@ -57,7 +56,7 @@ object AppUpdateClient {
|
|||
/**
|
||||
* Pin two Subject-Public-Key-Info SHA-256 hashes against
|
||||
* fdroid.sulkta.com so an off-tree CA misissue can't ship the
|
||||
* user an attacker-signed index. Round-67 audit HIGH-5.
|
||||
* user an attacker-signed index.
|
||||
*
|
||||
* - sha256/8ofd... — current leaf SPKI. Rotates every ~90 days
|
||||
* with each Let's Encrypt renewal; an app update before the
|
||||
|
|
@ -101,7 +100,7 @@ object AppUpdateClient {
|
|||
.maxByOrNull { it.manifest.versionCode }
|
||||
?: return@runCatchingCancellable null
|
||||
// Reject implausible versionCodes outright — see
|
||||
// MAX_PLAUSIBLE_VC. Round-67 audit HIGH-5.
|
||||
// MAX_PLAUSIBLE_VC.
|
||||
if (best.manifest.versionCode <= 0 ||
|
||||
best.manifest.versionCode > MAX_PLAUSIBLE_VC) {
|
||||
strawLogW("StrawUpdate") {
|
||||
|
|
@ -111,7 +110,6 @@ object AppUpdateClient {
|
|||
}
|
||||
// Strict APK-basename match before we hand this off to
|
||||
// ACTION_VIEW. Anything else gets logged + dropped.
|
||||
// Round-67 audit HIGH-5.
|
||||
val fileName = best.file.name
|
||||
if (!APK_NAME_RE.matches(fileName)) {
|
||||
strawLogW("StrawUpdate") {
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ class UpdateCheckWorker(
|
|||
runUpdateCheck(applicationContext)
|
||||
// Always succeed — a failed check just retries on the next
|
||||
// scheduled tick. Retry-with-backoff would burn battery for no
|
||||
// gain (the index is sticky and fdroid.sulkta.com is on Cobb's
|
||||
// own infra).
|
||||
// gain (the index is sticky and fdroid.sulkta.com is on Sulkta
|
||||
// infra, not a third-party rate limiter).
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,7 @@ object UpdateScheduler {
|
|||
}
|
||||
// WorkManager floors periodic intervals at 15 minutes.
|
||||
// coerceAtLeast(15) future-proofs against a smaller enum case
|
||||
// landing without anyone noticing the silent clamp. Round-67
|
||||
// audit MED-4.
|
||||
// landing without anyone noticing the silent clamp.
|
||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(
|
||||
interval.minutes.coerceAtLeast(15L),
|
||||
TimeUnit.MINUTES,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const val STRAW_USER_AGENT: String =
|
|||
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"
|
||||
|
||||
// OkHttpClient is internally thread-safe; lazy(SYNCHRONIZED) builds
|
||||
// exactly once across threads. Round-4 audit MED-6 — the prior
|
||||
// exactly once across threads. — the prior
|
||||
// synchronized(STRAW_USER_AGENT) locked an interned String literal
|
||||
// shared with any other code in any library that happened to lock
|
||||
// the same literal. Lazy-delegate avoids the global pool lock.
|
||||
|
|
|
|||
|
|
@ -46,8 +46,7 @@ object SponsorBlockClient {
|
|||
// string-concat built `?categories=["sponsor","selfpromo"]`
|
||||
// with literal brackets/quotes — SB happens to accept it
|
||||
// today, but the next time someone interpolates a non-enum
|
||||
// string in there it becomes a URL-construction bug. Round-4
|
||||
// audit MED-1 / LOW-2.
|
||||
// string in there it becomes a URL-construction bug.
|
||||
val url = "https://sponsor.ajay.app/api/skipSegments/$prefix".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addQueryParameter("categories", buildJsonArray(categories))
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
* Pure functions on StreamItem so any list-rendering site can call them
|
||||
* with one line at row-emit time.
|
||||
*
|
||||
* vc=56 ships only the shorts heuristic — paid/age require strawcore
|
||||
* flag plumbing landing in vc=57. The empty-stub fns are here so the
|
||||
* ships only the shorts heuristic — paid/age require strawcore
|
||||
* flag plumbing landing previously. The empty-stub fns are here so the
|
||||
* call sites we add now don't need to change when the flags arrive.
|
||||
*/
|
||||
|
||||
|
|
@ -30,14 +30,14 @@ fun looksLikeShort(item: StreamItem): Boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Placeholder until vc=57 adds an isPaid flag via strawcore-core.
|
||||
* Placeholder until adds an isPaid flag via strawcore-core.
|
||||
* Currently always false — the hide-paid toggle still shows up in
|
||||
* Settings so the user can pre-opt-in for when it lights up.
|
||||
*/
|
||||
fun looksLikePaid(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
|
||||
|
||||
/**
|
||||
* Placeholder until vc=57 adds an isAgeRestricted flag. Same shape
|
||||
* Placeholder until adds an isAgeRestricted flag. Same shape
|
||||
* as looksLikePaid.
|
||||
*/
|
||||
fun looksLikeAgeRestricted(@Suppress("UNUSED_PARAMETER") item: StreamItem): Boolean = false
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* Coroutine-safe runCatching. Standard kotlin.runCatching catches
|
||||
* Throwable — including CancellationException, which is supposed to
|
||||
* propagate so structured concurrency works. Round-5 audit HIGH-2
|
||||
* propagate so structured concurrency works.
|
||||
* surfaced the bug: a runCatching around a channelInfo() call
|
||||
* inside a cancelled job swallowed the cancellation, the job
|
||||
* carried on past the runCatching, hit its terminal write fence
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ object LogDump {
|
|||
runCatching {
|
||||
val pid = Process.myPid()
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
// Write to cacheDir/logs/ — vc=37 round-3 audit CVE MED-5
|
||||
// Write to cacheDir/logs/
|
||||
// narrowed the FileProvider scope from the whole cacheDir
|
||||
// to just this subdir, so dumps must land here.
|
||||
val logsDir = File(context.cacheDir, "logs").apply { mkdirs() }
|
||||
|
|
@ -105,7 +105,7 @@ object LogDump {
|
|||
* `playbackError`) can scrub Media3's `PlaybackException.message`
|
||||
* before rendering it to the user — that string includes the full
|
||||
* request URI for HttpDataSource exceptions, which would otherwise
|
||||
* be a leak via screenshot. vc=36 audit CVE HIGH-1.
|
||||
* be a leak via screenshot.
|
||||
*/
|
||||
fun scrubLine(line: String): String {
|
||||
var s = line
|
||||
|
|
@ -125,7 +125,6 @@ object LogDump {
|
|||
// Long tokens are unique enough to match anywhere. Short tokens
|
||||
// (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before
|
||||
// so we don't redact innocuous `n=42` counters from other libs.
|
||||
// vc=37 round-3 audit CVE-4.
|
||||
private val SIGNED_PARAM_LONG_RE = Regex(
|
||||
"""\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Shared YouTube-host allowlist. Originally lived inside
|
||||
* SettingsImport for the import-time URL check; round-4 audit
|
||||
* surfaced two more call sites — VideoDetailViewModel's auto
|
||||
* channelInfo(uploaderUrl) and recordWatch persistence — that
|
||||
* needed the same gate. Co-locating the set here so a future
|
||||
* host (yewtu.be, hypothetical YT mirror) is one edit instead of
|
||||
* three.
|
||||
* SettingsImport for the import-time URL check, then two more call
|
||||
* sites — VideoDetailViewModel's auto channelInfo(uploaderUrl) and
|
||||
* recordWatch persistence — needed the same gate. Co-locating the
|
||||
* set here so a future host (yewtu.be, hypothetical YT mirror) is
|
||||
* one edit instead of three.
|
||||
*/
|
||||
|
||||
package com.sulkta.straw.util
|
||||
|
|
@ -22,8 +21,7 @@ private val ALLOWED_YT_HOSTS: Set<String> = setOf(
|
|||
fun isAllowedYtUrl(url: String): Boolean {
|
||||
val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return false
|
||||
// Require an http/https scheme — `//host/...` (schemeless) and
|
||||
// `mailto:host` both parse with a host attribute. Round-5 audit
|
||||
// LOW-3.
|
||||
// `mailto:host` both parse with a host attribute.
|
||||
val scheme = uri.scheme?.lowercase() ?: return false
|
||||
if (scheme != "https" && scheme != "http") return false
|
||||
// Strip a single trailing dot (RFC FQDN form) before lookup.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
Narrowed to a `logs/` subdir of cacheDir (was the entire
|
||||
cacheDir) so a future bug that builds an attacker-influenced
|
||||
FileProvider URI can't reach SettingsImport workdirs or
|
||||
other cache state. vc=37 round-3 audit CVE MED-5. -->
|
||||
other cache state. -->
|
||||
<cache-path name="logs" path="logs/" />
|
||||
|
||||
<!-- Completed downloads. Downloader uses
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue