diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml
new file mode 100644
index 000000000..10d7847f3
--- /dev/null
+++ b/.forgejo/workflows/gitleaks.yml
@@ -0,0 +1,40 @@
+# .forgejo/workflows/gitleaks.yml
+#
+# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at
+# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered
+# (task #295).
+#
+# Pairs with the pre-receive hook installed on every bare repo — that one is
+# the strict enforcement layer (rejects the push); this one provides the
+# per-PR red ✗ that branch-protection rules can require before merge.
+#
+# Layer 1 (this workflow): visible per-PR status, can be a required check.
+# Layer 2 (pre-receive hook): strict enforcement at the server.
+# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos.
+
+name: gitleaks
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ scan:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Full history — gitleaks needs depth to scan a commit range.
+ fetch-depth: 0
+
+ - name: install gitleaks
+ run: |
+ curl -sSL -o gl.tar.gz \
+ https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz
+ tar xzf gl.tar.gz gitleaks
+ chmod +x gitleaks
+ ./gitleaks version
+
+ - name: scan
+ run: |
+ ./gitleaks detect --source . --no-banner --redact --verbose
diff --git a/README.md b/README.md
index aa4332165..6afc33878 100644
--- a/README.md
+++ b/README.md
@@ -1,153 +1,77 @@
-
We are rewriting large chunks of the codebase, to bring about a modern and stable NewPipe! You can download nightly builds here.
-
Please work on the refactor branch if you want to contribute new features. The current codebase is in maintenance mode and will only receive bugfixes.
+# Straw
-
-
NewPipe
-
A libre lightweight streaming front-end for Android.
+A Sulkta fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe). Android YouTube
+client, Compose UI, Media3 player, with [SponsorBlock](https://sponsor.ajay.app/)
+and [Return YouTube Dislike](https://returnyoutubedislike.com/) baked in.
-
+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.
-
-
+Add the repo in your F-Droid client of choice, then install Straw.
-*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)*
+The app also self-updates from the same repo when an APK lands there with a
+higher `versionCode`.
-> [!warning]
-> THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.
->
-> PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.
+## What's in
-## Screenshots
+- Search, video detail, channel pages, playlists
+- Inline player + fullscreen + minibar + background audio + PiP
+- Media3 ExoPlayer (DASH / HLS / progressive / merged DASH chunks)
+- SponsorBlock auto-skip (categories user-toggleable)
+- Return YouTube Dislike on video detail
+- RSS-based subscription feed (fast — ~1s for 50 subs)
+- Hide-shorts / hide-paid / hide-age-restricted feed filters
+- Resume positions + watch history + search history
+- Local playlists, downloads (video + audio)
+- NewPipe-format settings import (subs + playlists + history)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png)
-[](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png)
-
-[](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png)
-[](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png)
+## What's out (on purpose)
-### Supported Services
+- Trending / algorithmic feeds. Subscriptions only.
+- iOS / desktop targets. Android-only for now.
+- Google Play Services anything.
-NewPipe currently supports these services:
+## Layout
-
-* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube))
-* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube))
-* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp))
-* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud))
-* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club))
-
-As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile!
-
-Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube.
-
-If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor).
-
-## Description
-
-NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe.
-
-Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed.
-
-### Features
-
-* Watch videos at resolutions up to 4K
-* Listen to audio in the background, only loading the audio stream to save data
-* Popup mode (floating player, aka Picture-in-Picture)
-* Watch live streams
-* Show/hide subtitles/closed captions
-* Search videos and audios (on YouTube, you can specify the content language as well)
-* Enqueue videos (and optionally save them as local playlists)
-* Show/hide general information about videos (such as description and tags)
-* Show/hide next/related videos
-* Show/hide comments
-* Search videos, audios, channels, playlists and albums
-* Browse videos and audios within a channel
-* Subscribe to channels (yes, without logging into any account!)
-* Get notifications about new videos from channels you're subscribed to
-* Create and edit channel groups (for easier browsing and management)
-* Browse video feeds generated from your channel groups
-* View and search your watch history
-* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing)
-* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service)
-* Download videos/audios/subtitles (closed captions)
-* Open in Kodi
-* Watch/Block age-restricted material
-
-
-
-
-## Installation and updates
-You can install NewPipe using one of the following methods:
- 1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
- 2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases), [compare the signing key](#apk-info) and install it.
- 3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users.
- 4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods.
- 5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up.
-
-We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK.
-
-In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure:
-1. Back up your data via Settings > Backup and Restore > Export Database so you keep your history, subscriptions, and playlists
-2. Uninstall NewPipe
-3. Download the APK from the new source and install it
-4. Import the data from step 1 via Settings > Backup and Restore > Import Database
-
-> [!Note]
-> When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.
-
-### APK Info
-
-This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2.
```
-CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB
+strawApp/ Sulkta-authored app — Compose UI, Media3 wiring, SB + RYD clients
+rust/ strawcore — UniFFI wrapper around the Rust extractor
+shared/ KMP scaffold inherited from upstream NewPipe (unused for now)
+app/ Upstream NewPipe :app module — kept for reference
```
-## Contribution
-Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
+## Build
-
-
-
+```
+./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:
-
-
-
-
-
-
-
+```
+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 . Upstream changes
+get pulled periodically via the `upstream` remote.
+
+## Disclaimer
+
+Not affiliated with YouTube, Google, NewPipe e.V., the SponsorBlock project,
+or Return YouTube Dislike. Trademarks belong to their owners. Straw uses
+public web endpoints; nothing here authenticates to any account.
diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt
index 8577a49eb..5a4d5debc 100644
--- a/buildSrc/src/main/kotlin/ProjectConfig.kt
+++ b/buildSrc/src/main/kotlin/ProjectConfig.kt
@@ -15,6 +15,46 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
-const val STRAW_VERSION_CODE = 18
-const val STRAW_VERSION_NAME = "0.1.0-AD"
+//
+// vc=23 / 0.1.0-AI — minibar + downloads UI + green theme:
+// * MediaController/MediaSessionService unification — single ExoPlayer
+// owned by PlaybackService, every UI surface is a controller client.
+// Inline player on VideoDetail, fullscreen Player, and the new
+// minibar overlay all drive the same underlying player; nothing
+// restarts on screen transitions.
+// * Persistent minibar overlay at the bottom of every non-Player
+// screen whenever something is loaded. Tap → expand to fullscreen.
+// Drag-down on fullscreen → minimize to minibar. ⌄ overlay button
+// also minimizes. × on the minibar stops + clears.
+// * Downloads page wired into the drawer.
+// * Theme: forest-green primary palette in place of M3 default
+// lavender / NewPipe red — modern, clean, distinct.
+//
+// vc=22 / 0.1.0-AH — V-2 player polish + local playlists:
+// * Inline → fullscreen now hands off seek position. Tap Play (or the
+// ⛶ pill on the inline player) while the inline is mid-track and
+// the fullscreen Player picks up at the same point. Same handoff
+// pattern as fullscreen → background from vc=21.
+// * Local playlists: drawer entry "Playlists", "Save" button on
+// VideoDetail. SharedPreferences-backed, no queue/autoplay yet
+// (tap an entry to open VideoDetail as normal).
+//
+// vc=21 / 0.1.0-AG — player hand-off polish:
+// * 🎧 background-audio button now captures the current position and
+// resumes the foreground service from there instead of restarting.
+// * HOME / recents button while on the player now hands off seamlessly
+// to background audio (same position-preserving path) instead of
+// auto-entering Picture-in-Picture. Manual PiP via the ⊟ overlay
+// button is unchanged.
+//
+// vc=20 / 0.1.0-AF — channel-videos fix on top of the rust pipeline
+// cutover. vc=19 returned empty subscription feeds because
+// strawcore-core's channel_info wasn't doing the second browse for the
+// Videos tab AND wasn't parsing the new lockupViewModel shape.
+//
+// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
+// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
+// NewPipeExtractor in the runtime path.
+const val STRAW_VERSION_CODE = 71
+const val STRAW_VERSION_NAME = "0.1.0-CE"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 7ee7f3ec7..880e1f4b7 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -14,7 +14,7 @@ members = ["strawcore"]
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Sulkta-Coop"]
-repository = "http://192.168.0.5:3001/Sulkta-Coop/straw"
+repository = "https://git.sulkta.com/Sulkta-Coop/straw"
[profile.release]
# Strip debug info, run thin LTO. APK size matters more than build time here.
@@ -29,6 +29,6 @@ opt-level = "z"
url = "2"
[profile.dev]
-# Keep debug builds fast — we're rebuilding constantly during U-1..U-5.
+# Keep debug builds fast — we rebuild often during NDK cross-compile.
opt-level = 0
debug = 1
diff --git a/rust/README.md b/rust/README.md
index 2aa515b4c..720f81fa9 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -20,12 +20,12 @@ moves to Rust.
## Build chain
```
-crafting-table
+Build container (Sulkta uses one; any toolchain matching this layout works)
├── rustup stable (target add: aarch64-linux-android, armv7-linux-androideabi,
│ x86_64-linux-android, i686-linux-android)
├── cargo-ndk (cross-compile helper)
├── android-sdk (ANDROID_HOME, sdkmanager, build-tools, platforms)
-└── android-ndk (ANDROID_NDK_HOME, r27c LTS at /caches/android-sdk/ndk/...)
+└── android-ndk (ANDROID_NDK_HOME, r27c LTS)
Gradle (strawApp/build.gradle.kts)
├── cargoBuild Exec task → cargo ndk -t ... -o jniLibs/ build --release
diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml
index f693973ee..6ccd53fd5 100644
--- a/rust/strawcore/Cargo.toml
+++ b/rust/strawcore/Cargo.toml
@@ -30,15 +30,20 @@ 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"
-# Single-threaded init for the runtime + extractor singletons.
-once_cell = "1"
# Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`.
log = "0.4"
android_logger = { version = "0.14", default-features = false }
+# 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.
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "stream"] }
+quick-xml = "0.36"
+futures = "0.3"
[build-dependencies]
uniffi = { version = "0.28", features = ["build"] }
diff --git a/rust/strawcore/src/channel.rs b/rust/strawcore/src/channel.rs
index 67d48b8a9..ec99e1416 100644
--- a/rust/strawcore/src/channel.rs
+++ b/rust/strawcore/src/channel.rs
@@ -23,7 +23,8 @@ pub struct ChannelInfo {
#[uniffi::export(async_runtime = "tokio")]
pub async fn channel_info(input: String) -> Result {
- log::info!("strawcore::channel_info input={}", input);
+ log::info!("strawcore::channel_info input_len={}", input.len());
+ crate::runtime::ensure_initialized();
let identifier = resolve_channel_identifier(&input)?;
let core = tokio::task::spawn_blocking(move || core_channel_info(identifier))
.await
diff --git a/rust/strawcore/src/error.rs b/rust/strawcore/src/error.rs
index 34069bb81..2703813b5 100644
--- a/rust/strawcore/src/error.rs
+++ b/rust/strawcore/src/error.rs
@@ -31,13 +31,47 @@ pub enum StrawcoreError {
RequiresLogin { detail: String },
}
+/// Drop the `continue=` param from a google.com/sorry/...
+/// URL while leaving every other param intact. Used only for surfacing
+/// recaptcha challenge URLs to the UI; keeps the URL tappable for the
+/// user to solve the challenge while scrubbing the embedded
+/// googlevideo signature.
+fn strip_continue_param(url: &str) -> String {
+ let (base, query) = match url.split_once('?') {
+ Some(pair) => pair,
+ None => return url.to_owned(),
+ };
+ let filtered: Vec<&str> = query
+ .split('&')
+ .filter(|kv| {
+ let key = kv.split_once('=').map(|(k, _)| k).unwrap_or(*kv);
+ !key.eq_ignore_ascii_case("continue")
+ })
+ .collect();
+ if filtered.is_empty() {
+ base.to_owned()
+ } else {
+ format!("{}?{}", base, filtered.join("&"))
+ }
+}
+
impl From for StrawcoreError {
fn from(e: strawcore_core::exceptions::ExtractionError) -> Self {
use strawcore_core::exceptions::{ContentUnavailable, ExtractionError, NetworkError};
match e {
ExtractionError::Network(NetworkError::Recaptcha { url }) => {
+ // Strip the `continue=` query param before propagating.
+ // google.com/sorry/index carries the full signed
+ // googlevideo URL in `continue=` so the user can be
+ // sent back to the stream after solving — but
+ // surfacing that to the UI is a credential leak via
+ // screenshot, and Kotlin's LogDump scrubber only
+ // 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.
StrawcoreError::RequiresLogin {
- detail: format!("reCAPTCHA at {url}"),
+ detail: format!("reCAPTCHA challenge: {}", strip_continue_param(&url)),
}
}
ExtractionError::Network(NetworkError::Transport(msg)) => {
diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs
new file mode 100644
index 000000000..c50b2942c
--- /dev/null
+++ b/rust/strawcore/src/feed.rs
@@ -0,0 +1,505 @@
+// 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
+// per-channel `channel_info()` page-scrape that was costing ~500ms each
+// (the bottleneck behind NewPipe's "pull to refresh takes 30 seconds for
+// 50 subs" UX). Fan-out 50× concurrent via `futures::stream::buffer_unordered`
+// turns a 50-sub refresh from ~5-8s parallel-12 to ~1s parallel-50.
+//
+// RSS is intentionally lossy — it returns title/url/published/thumbnail
+// only. No duration, no view count, no shorts/age/paid flags. That's the
+// right trade for a feed-refresh use case: tap-through still goes through
+// the full stream_info path to fetch the rich metadata when actually
+// needed.
+
+use std::sync::OnceLock;
+use std::time::Duration;
+
+use futures::stream::{self, StreamExt};
+use reqwest::Client;
+
+use crate::error::StrawcoreError;
+use crate::search::SearchItem;
+
+const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id=";
+const MAX_CONCURRENT: usize = 50;
+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.
+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.
+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.
+const YEAR_MIN: i32 = 1970;
+const YEAR_MAX: i32 = 2200;
+
+/// Hybrid-backfill metadata: just the two fields RSS doesn't return
+/// (view count + duration). Kotlin calls this lazily for visible feed
+/// items after the RSS-fed paint to fill in the gaps that
+/// channel_feed_rss leaves empty.
+///
+/// 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
+/// we'll discard) — future opt would be to parse the watch-page HTML
+/// JSON state directly for just these two fields. ~100ms savings per
+/// call but ~150 lines of HTML/JSON pluck logic. Punted until needed.
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct EnrichedFeedMetadata {
+ pub view_count: i64,
+ pub duration_seconds: i64,
+}
+
+#[uniffi::export(async_runtime = "tokio")]
+pub async fn enrich_feed_item(
+ video_url: String,
+) -> Result {
+ crate::runtime::ensure_initialized();
+ let info = crate::stream::stream_info(video_url).await?;
+ Ok(EnrichedFeedMetadata {
+ view_count: info.view_count,
+ duration_seconds: info.duration_seconds,
+ })
+}
+
+/// 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.
+static RSS_CLIENT: OnceLock = OnceLock::new();
+
+fn rss_client() -> Result<&'static Client, StrawcoreError> {
+ if let Some(c) = RSS_CLIENT.get() {
+ return Ok(c);
+ }
+ let client = Client::builder()
+ .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.
+ .redirect(reqwest::redirect::Policy::limited(3))
+ .build()
+ .map_err(|e| StrawcoreError::Extractor {
+ msg: format!("http client build: {e}"),
+ })?;
+ Ok(RSS_CLIENT.get_or_init(|| client))
+}
+
+/// Single-channel RSS — Kotlin keeps its per-channel cache + fan-out
+/// (parallelism cranked to 50 in the wrapper). Each call is ~50-150ms
+/// instead of the ~500ms channelInfo page-scrape, so a 50-sub refresh
+/// drops from ~5-8s to ~1s.
+#[uniffi::export(async_runtime = "tokio")]
+pub async fn channel_feed_rss(
+ channel_url: String,
+) -> Result, StrawcoreError> {
+ crate::runtime::ensure_initialized();
+ log::info!("strawcore::channel_feed_rss url_len={}", channel_url.len());
+ let client = rss_client()?;
+ Ok(fetch_channel_rss(client, &channel_url).await.unwrap_or_default())
+}
+
+/// Bulk subscription feed fan-out — for callers that want one round-trip
+/// to Rust. Currently unused by the Android app (it sticks with the
+/// per-channel cache), but exposed for future desktop / web variants
+/// or for a "warm everything" background prefetch.
+#[uniffi::export(async_runtime = "tokio")]
+pub async fn subscription_feed(
+ channel_urls: Vec,
+) -> Result, StrawcoreError> {
+ crate::runtime::ensure_initialized();
+ log::info!("strawcore::subscription_feed channels={}", channel_urls.len());
+ if channel_urls.is_empty() {
+ return Ok(Vec::new());
+ }
+ let client = rss_client()?;
+
+ let results: Vec> = stream::iter(channel_urls.into_iter())
+ .map(|url| async move { fetch_channel_rss(client, &url).await.unwrap_or_default() })
+ .buffer_unordered(MAX_CONCURRENT)
+ .collect()
+ .await;
+
+ // 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. (an earlier version sorted lexicographically
+ // on the relative-date STRING, which is wrong because "10 hours
+ // ago" < "2 hours ago" in cmp order)
+ Ok(results.into_iter().flatten().collect())
+}
+
+async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option> {
+ let channel_id = extract_channel_id(channel_url)?;
+ let url = format!("{RSS_BASE}{channel_id}");
+ let resp = client
+ .get(&url)
+ .send()
+ .await
+ .ok()?
+ .error_for_status()
+ .ok()?;
+ // Streaming body read with a hard byte cap — `.text()` reads
+ // 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.
+async fn read_capped_body(resp: reqwest::Response) -> Option {
+ use futures::StreamExt;
+ let mut total = 0usize;
+ let mut buf: Vec = Vec::with_capacity(32 * 1024);
+ let mut stream = resp.bytes_stream();
+ while let Some(chunk_result) = stream.next().await {
+ let chunk = chunk_result.ok()?;
+ // Defense-in-depth: a single hostile chunk can be arbitrarily
+ // 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.
+ if chunk.len() > RSS_MAX_BYTES {
+ log::warn!("strawcore::rss single chunk {} exceeds cap; aborting", chunk.len());
+ return None;
+ }
+ total = total.saturating_add(chunk.len());
+ if total > RSS_MAX_BYTES {
+ log::warn!("strawcore::rss body exceeded {RSS_MAX_BYTES} bytes; aborting");
+ return None;
+ }
+ buf.extend_from_slice(&chunk);
+ }
+ // 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-
+ // empty handles broken entries downstream.
+ Some(String::from_utf8_lossy(&buf).into_owned())
+}
+
+/// Extract the `UCxxx` channel ID from a channel URL. Accepts the
+/// shapes the Android app actually has in Subscriptions plus the ones
+/// users paste from share intents:
+/// * `https://www.youtube.com/channel/UCxxx...`
+/// * `https://youtube.com/channel/UCxxx...`
+/// * `http(s)://m.youtube.com/channel/UCxxx...`
+/// * trailing `/videos`, `?si=...`, etc — anything after the ID is dropped
+/// * raw `UCxxx...` (already an ID)
+///
+/// Real YT channel IDs are EXACTLY 24 chars (`UC` + 22 base64-ish).
+///
+/// `@handle` URLs are NOT supported here — RSS requires the channel ID.
+/// Callers with @handles should resolve via channel_info() once and
+/// cache the ID into Subscriptions.
+fn extract_channel_id(input: &str) -> Option {
+ let trimmed = input.trim();
+ let trimmed_lower = trimmed.to_lowercase();
+ // Match the ":///channel/" prefix in a single sweep
+ // so we accept http/https + www./m. variants without four-way
+ // string-strip ladders. ANCHORED at the start of the string —
+ // 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.
+ const PREFIXES: &[&str] = &[
+ "https://www.youtube.com/channel/",
+ "https://youtube.com/channel/",
+ "https://m.youtube.com/channel/",
+ "http://www.youtube.com/channel/",
+ "http://youtube.com/channel/",
+ "http://m.youtube.com/channel/",
+ ];
+ for p in PREFIXES {
+ if let Some(rest) = trimmed_lower.strip_prefix(p) {
+ // Bytes match 1:1 with `trimmed` since the prefix is ASCII
+ // and case-folding ASCII doesn't change byte length.
+ let rest_in_original = &trimmed[p.len()..p.len() + rest.len()];
+ let id = rest_in_original
+ .split(|c: char| c == '/' || c == '?' || c == '#')
+ .next()?;
+ return validate_channel_id(id);
+ }
+ }
+ validate_channel_id(trimmed)
+}
+
+/// A real YouTube channel ID is `UC` followed by exactly 22 chars from
+/// `[A-Za-z0-9_-]`.
+fn validate_channel_id(id: &str) -> Option {
+ if id.len() != 24 || !id.starts_with("UC") {
+ return None;
+ }
+ if !id.bytes().skip(2).all(|b| {
+ matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')
+ }) {
+ return None;
+ }
+ Some(id.to_string())
+}
+
+fn parse_rss(body: &str, channel_id: String) -> Option> {
+ use quick_xml::events::Event;
+ use quick_xml::Reader;
+
+ let mut reader = Reader::from_str(body);
+ reader.config_mut().trim_text(true);
+
+ let mut buf = Vec::new();
+ let mut items: Vec = Vec::new();
+
+ // Per-entry scratch.
+ let mut in_entry = false;
+ let mut depth = 0u8;
+ let mut video_id = String::new();
+ let mut title = String::new();
+ let mut uploader = String::new();
+ let mut uploader_url = String::new();
+ let mut thumbnail: Option = None;
+ let mut published = String::new();
+
+ // What text-collecting state we're in. Replaced per element open.
+ let mut text_target: Option = None;
+
+ loop {
+ match reader.read_event_into(&mut buf) {
+ Ok(Event::Start(e)) => {
+ let name = e.name();
+ let local = local_name(name.as_ref());
+ if local == "entry" {
+ in_entry = true;
+ depth = 0;
+ video_id.clear();
+ title.clear();
+ uploader.clear();
+ uploader_url.clear();
+ thumbnail = None;
+ published.clear();
+ }
+ if !in_entry {
+ continue;
+ }
+ depth = depth.saturating_add(1);
+ text_target = match local {
+ "videoId" => Some(TextTarget::VideoId),
+ "title" if depth <= 2 => Some(TextTarget::Title),
+ "name" => Some(TextTarget::UploaderName),
+ "uri" => Some(TextTarget::UploaderUrl),
+ "published" => Some(TextTarget::Published),
+ _ => None,
+ };
+ }
+ Ok(Event::Empty(e)) => {
+ if !in_entry {
+ continue;
+ }
+ let name = e.name();
+ let local = local_name(name.as_ref());
+ // is self-closing.
+ if local == "thumbnail" {
+ for attr in e.attributes().flatten() {
+ if attr.key.as_ref() == b"url" {
+ if let Ok(v) = attr.unescape_value() {
+ thumbnail = Some(v.into_owned());
+ }
+ }
+ }
+ }
+ }
+ Ok(Event::Text(t)) => {
+ if !in_entry {
+ continue;
+ }
+ let Ok(s) = t.unescape() else { continue };
+ let s = s.as_ref();
+ match text_target {
+ Some(TextTarget::VideoId) => video_id.push_str(s),
+ Some(TextTarget::Title) => title.push_str(s),
+ Some(TextTarget::UploaderName) => uploader.push_str(s),
+ Some(TextTarget::UploaderUrl) => uploader_url.push_str(s),
+ Some(TextTarget::Published) => published.push_str(s),
+ None => {}
+ }
+ }
+ Ok(Event::End(e)) => {
+ if !in_entry {
+ continue;
+ }
+ let name = e.name();
+ let local = local_name(name.as_ref());
+ if local == "entry" {
+ // 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.
+ if !video_id.is_empty() && !title.is_empty() && !published.is_empty() {
+ items.push(SearchItem {
+ url: format!("https://www.youtube.com/watch?v={video_id}"),
+ title: title.clone(),
+ uploader: uploader.clone(),
+ uploader_url: if uploader_url.is_empty() {
+ Some(format!("https://www.youtube.com/channel/{channel_id}"))
+ } else {
+ Some(uploader_url.clone())
+ },
+ thumbnail: thumbnail.clone(),
+ duration_seconds: 0,
+ view_count: 0,
+ // RSS gives RFC3339 timestamps. Convert to
+ // the human-relative format Kotlin's
+ // recencyScore parser expects ("N units
+ // 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 +
+ // WTYP landed at top because they resolved
+ // first in the fan-out. Caught 2026-05-26.
+ upload_date_relative: iso_to_relative(&published),
+ });
+ if items.len() >= RSS_MAX_ENTRIES {
+ // Defense-in-depth against a feed that
+ // ships thousands of blocks.
+ return Some(items);
+ }
+ }
+ in_entry = false;
+ depth = 0;
+ } else {
+ depth = depth.saturating_sub(1);
+ }
+ text_target = None;
+ }
+ Ok(Event::Eof) => break,
+ // Partial-parse on error: return whatever we've already
+ // collected rather than throwing the whole batch away.
+ // A truncated body (EOF mid-stream on a flaky network)
+ // would otherwise silently disappear the channel.
+ Err(e) => {
+ log::warn!("strawcore::rss parse error after {} items: {e}", items.len());
+ return Some(items);
+ }
+ _ => {}
+ }
+ buf.clear();
+ }
+ Some(items)
+}
+
+enum TextTarget {
+ VideoId,
+ Title,
+ UploaderName,
+ UploaderUrl,
+ Published,
+}
+
+/// Parse an RFC3339 timestamp (`2026-05-25T15:00:00+00:00`) into "N
+/// units ago". Drops the timezone offset — YT RSS always serves UTC
+/// and the granularity is days at most, so a ±14h skew doesn't matter
+/// for the relative display.
+///
+/// Falls back to the raw string if parsing fails. That keeps the UI
+/// readable even on a malformed feed (rare).
+fn iso_to_relative(iso: &str) -> String {
+ let secs = match parse_rfc3339_secs(iso) {
+ Some(s) => s,
+ None => return iso.to_string(),
+ };
+ let now_secs = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs() as i64)
+ .unwrap_or(0);
+ // A device with a skewed clock can see RSS timestamps as future-
+ // dated. saturating_sub returns 0 → "0 seconds ago" → sorts to
+ // 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.
+ if secs > now_secs {
+ return "just now".to_string();
+ }
+ format_relative(now_secs - secs)
+}
+
+fn parse_rfc3339_secs(s: &str) -> Option {
+ if s.len() < 19 {
+ return None;
+ }
+ let date = s.get(..10)?;
+ let time = s.get(11..19)?;
+ if !s.is_char_boundary(10) || s.as_bytes().get(10) != Some(&b'T') {
+ return None;
+ }
+ let mut date_parts = date.split('-');
+ let y: i32 = date_parts.next()?.parse().ok()?;
+ let m: u32 = date_parts.next()?.parse().ok()?;
+ let d: u32 = date_parts.next()?.parse().ok()?;
+ let mut time_parts = time.split(':');
+ let hh: u32 = time_parts.next()?.parse().ok()?;
+ let mm: u32 = time_parts.next()?.parse().ok()?;
+ let ss: u32 = time_parts.next()?.parse().ok()?;
+ // 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.
+ if !(YEAR_MIN..=YEAR_MAX).contains(&y) {
+ return None;
+ }
+ if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
+ return None;
+ }
+ let days = civil_to_days(y, m, d);
+ Some(days * 86_400 + hh as i64 * 3_600 + mm as i64 * 60 + ss as i64)
+}
+
+/// Howard Hinnant's days-since-1970-01-01 algorithm. Standard,
+/// branch-free, handles negative years correctly. Source: chrono
+/// proposal for C++20.
+fn civil_to_days(y: i32, m: u32, d: u32) -> i64 {
+ let y = if m <= 2 { y - 1 } else { y };
+ let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
+ let yoe = (y - era * 400) as u32;
+ let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ era as i64 * 146_097 + doe as i64 - 719_468
+}
+
+fn format_relative(age_secs: i64) -> String {
+ let s = age_secs.max(0);
+ fn unit(n: i64, name: &str) -> String {
+ format!("{} {}{} ago", n, name, if n == 1 { "" } else { "s" })
+ }
+ if s < 60 {
+ unit(s, "second")
+ } else if s < 3_600 {
+ unit(s / 60, "minute")
+ } else if s < 86_400 {
+ unit(s / 3_600, "hour")
+ } else if s < 604_800 {
+ unit(s / 86_400, "day")
+ } else if s < 2_592_000 {
+ unit(s / 604_800, "week")
+ } else if s < 31_536_000 {
+ unit(s / 2_592_000, "month")
+ } else {
+ unit(s / 31_536_000, "year")
+ }
+}
+
+/// Strip the namespace prefix off an XML element name. YouTube's feed
+/// is heavily namespaced (`yt:videoId`, `media:thumbnail`) but we only
+/// care about the local part — namespace-vs-local distinguishing
+/// would just bloat the matcher.
+fn local_name(qualified: &[u8]) -> &str {
+ let s = std::str::from_utf8(qualified).unwrap_or("");
+ match s.rfind(':') {
+ Some(idx) => &s[idx + 1..],
+ None => s,
+ }
+}
diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs
index 1ca13d06c..c8353502a 100644
--- a/rust/strawcore/src/lib.rs
+++ b/rust/strawcore/src/lib.rs
@@ -12,6 +12,7 @@ use std::sync::Once;
mod channel;
mod error;
+mod feed;
mod runtime;
mod search;
mod stream;
@@ -39,9 +40,12 @@ pub fn init_logging() {
}
/// Smoke-test entry point — round-trip a string through JNI.
+/// Used during the initial UniFFI bring-up; kept for future smoke
+/// debugging. Logs shape only — the `name` value never hits logcat
+/// because a future caller might pass a real user-supplied string.
#[uniffi::export]
pub fn hello_from_rust(name: String) -> String {
- log::info!("hello_from_rust called with name={}", name);
+ log::info!("hello_from_rust called name_len={}", name.len());
format!(
"hello {} from rust 🦀 (strawcore v{})",
name,
diff --git a/rust/strawcore/src/runtime.rs b/rust/strawcore/src/runtime.rs
index 6e1b3fbc0..336d6b4bb 100644
--- a/rust/strawcore/src/runtime.rs
+++ b/rust/strawcore/src/runtime.rs
@@ -1,29 +1,96 @@
// Runtime bootstrap. Called once from Kotlin's StrawApp.onCreate via
-// init_logging(). Wires the strawcore-core Downloader + Localization
-// singleton so the extractor calls have an HTTP client to use.
+// init_logging(), and again before every strawcore call. Wires the
+// strawcore-core Downloader + Localization singleton so the extractor
+// has an HTTP client to use.
+//
+// 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
+// consumed, NewPipe::init_full never ran, and every subsequent
+// search/streamInfo/channelInfo returned DownloaderMissing for the
+// rest of the process lifetime.
+//
+// New shape: use an AtomicBool to track success. Only "success" closes
+// the door. On failure we retry — rate-limited so a persistently-broken
+// network doesn't hammer reqwest::Client::new() on every call.
-use std::sync::{Arc, Once};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
use strawcore_core::downloader::ReqwestDownloader;
use strawcore_core::localization::{ContentCountry, Localization};
use strawcore_core::newpipe::NewPipe;
-static INIT: Once = Once::new();
+static INITIALIZED: AtomicBool = AtomicBool::new(false);
+static LAST_ATTEMPT_MS: AtomicU64 = AtomicU64::new(0);
+// Guards the actual init attempt so concurrent calls don't all try
+// to build the downloader in parallel; serial retry is the goal.
+static INIT_LOCK: Mutex<()> = Mutex::new(());
+
+/// Min ms between retries when init has failed. 5s — enough that a
+/// hot loop of failed searches doesn't pin a CPU on reqwest setup,
+/// short enough that a user who toggled airplane mode off recovers
+/// within one tap.
+const RETRY_BACKOFF_MS: u64 = 5_000;
+
+fn now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0)
+}
pub fn ensure_initialized() {
- INIT.call_once(|| {
- match ReqwestDownloader::new() {
- Ok(dl) => {
- NewPipe::init_full(
- Arc::new(dl),
- Localization::default(),
- ContentCountry::default(),
- );
- log::info!("strawcore-core: downloader + localization initialized");
- }
- Err(e) => {
- log::error!("strawcore-core: failed to build downloader: {e}");
- }
+ // Fast path: already initialized. Single Acquire load.
+ if INITIALIZED.load(Ordering::Acquire) {
+ return;
+ }
+ // Backoff check BEFORE the lock — a recent failure shouldn't
+ // make N concurrent callers queue on a mutex they'll all skip
+ // out of anyway.
+ let last = LAST_ATTEMPT_MS.load(Ordering::Acquire);
+ let now = now_ms();
+ if last != 0 && now.saturating_sub(last) < RETRY_BACKOFF_MS {
+ return;
+ }
+ // try_lock — if another thread is already mid-init, return
+ // immediately rather than block. The caller will get
+ // 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. was the regression on round-5's
+ // mutex-first ordering.
+ let _guard = match INIT_LOCK.try_lock() {
+ Ok(g) => g,
+ Err(_) => return,
+ };
+ // Re-check under the lock — another thread may have just succeeded.
+ if INITIALIZED.load(Ordering::Acquire) {
+ return;
+ }
+ match ReqwestDownloader::new() {
+ Ok(dl) => {
+ NewPipe::init_full(
+ Arc::new(dl),
+ Localization::default(),
+ ContentCountry::default(),
+ );
+ INITIALIZED.store(true, Ordering::Release);
+ // Clear LAST_ATTEMPT_MS so a future hypothetical
+ // re-init path (none today) wouldn't see cooldown
+ // bleed from this success.
+ LAST_ATTEMPT_MS.store(0, Ordering::Release);
+ log::info!("strawcore-core: downloader + localization initialized");
}
- });
+ Err(e) => {
+ // Stamp the timestamp on FAILURE only, so the next
+ // caller within RETRY_BACKOFF_MS skips, but a successful
+ // attempt isn't reflected in the backoff state.
+ LAST_ATTEMPT_MS.store(now, Ordering::Release);
+ log::error!("strawcore-core: downloader init failed (will retry on next call)");
+ let _ = e;
+ }
+ }
}
diff --git a/rust/strawcore/src/search.rs b/rust/strawcore/src/search.rs
index b4f96395e..f99c29da9 100644
--- a/rust/strawcore/src/search.rs
+++ b/rust/strawcore/src/search.rs
@@ -20,6 +20,10 @@ pub struct SearchItem {
pub duration_seconds: i64,
/// Reported view count. 0 = unknown.
pub view_count: i64,
+ /// Relative upload date as YT renders it ("2 days ago", "3 weeks
+ /// ago"). Empty if not extracted. Strawcore-core already populates
+ /// this on StreamInfoItem; we just pass it through.
+ pub upload_date_relative: String,
}
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
@@ -44,12 +48,23 @@ pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
} else {
item.view_count
},
+ upload_date_relative: item.upload_date_relative,
}
}
#[uniffi::export(async_runtime = "tokio")]
pub async fn search(query: String) -> Result, StrawcoreError> {
- log::info!("strawcore::search query={}", query);
+ // Don't log the query itself — searches are PII (sometimes
+ // 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.
+ log::info!("strawcore::search query_len={}", query.len());
+ // 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).
+ crate::runtime::ensure_initialized();
let result = tokio::task::spawn_blocking(move || {
search_extractor::search(&query, SearchFilter::Videos)
})
diff --git a/rust/strawcore/src/stream.rs b/rust/strawcore/src/stream.rs
index 7a4ce6839..f28080f9c 100644
--- a/rust/strawcore/src/stream.rs
+++ b/rust/strawcore/src/stream.rs
@@ -57,7 +57,8 @@ pub struct AudioStreamItem {
#[uniffi::export(async_runtime = "tokio")]
pub async fn stream_info(input: String) -> Result {
- log::info!("strawcore::stream_info input={}", input);
+ log::info!("strawcore::stream_info input_len={}", input.len());
+ crate::runtime::ensure_initialized();
let video_id = resolve_video_id(&input)?;
let video_id_for_call = video_id.clone();
let core = tokio::task::spawn_blocking(move || core_stream_info(&video_id_for_call))
diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts
index 13ef39dde..13fbac20b 100644
--- a/strawApp/build.gradle.kts
+++ b/strawApp/build.gradle.kts
@@ -39,13 +39,30 @@ configure {
}
buildTypes {
+ // R8 enabled on BOTH variants — we publish the debug APK to
+ // fdroid (com.sulkta.straw.debug) per the existing pipeline,
+ // and audit-flagged Log.d strips depended on R8 actually
+ // running on the variant we ship. Keep rules in
+ // strawApp/proguard-rules.pro cover UniFFI + JNA +
+ // kotlinx-serialization companions.
debug {
isDebuggable = true
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Straw debug")
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
}
release {
- isMinifyEnabled = false
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
}
}
@@ -81,7 +98,7 @@ dependencies {
implementation(libs.jetbrains.compose.foundation)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui)
- implementation("androidx.compose.material:material-icons-core:1.7.5")
+ implementation("androidx.compose.material:material-icons-extended:1.7.5")
// Lifecycle + ViewModel for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
@@ -95,7 +112,9 @@ dependencies {
implementation(libs.coil.network.okhttp)
// NewPipeExtractor (JVM/Android-only) + its OkHttp dep
- implementation(libs.newpipe.extractor)
+ // libs.newpipe.extractor — REMOVED in Path C-6. Extractor is now strawcore
+ // (Rust + rustypipe via UniFFI). See rust/strawcore/ + the cargoBuild +
+ // uniffiBindgen Gradle tasks below.
implementation(libs.squareup.okhttp)
// JSON for SponsorBlock + Return YouTube Dislike clients
@@ -110,4 +129,101 @@ dependencies {
implementation("androidx.media3:media3-session:1.4.1")
// Guava ListenableFuture support for awaiting MediaController connect.
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
+
+ // WorkManager — periodic background poll of fdroid.sulkta.com index
+ // for self-update notifications. CoroutineWorker is built into the
+ // base work-runtime artifact as of 2.10.
+ implementation(libs.androidx.work.runtime)
+
+ // strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the
+ // cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
+ // streamInfo / channelInfo to replace NewPipeExtractor.
+ implementation("net.java.dev.jna:jna:5.14.0@aar")
}
+
+// =============================================================================
+// Phase U-1 / Path-C-2 — Rust core build glue.
+//
+// Two tasks chain into the Android build:
+// cargoBuild — cross-compiles rust/strawcore for the four Android ABIs
+// via cargo-ndk and drops the .so files in strawApp/src/main/jniLibs/.
+// uniffiBindgen — generates the Kotlin bindings from the freshly-built lib
+// into strawApp/src/main/java/uniffi/strawcore/.
+//
+// Both depend on:
+// - 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 Sulkta build container.
+// =============================================================================
+
+val rustRoot = file("../rust").absolutePath
+val jniLibsDir = file("src/main/jniLibs").absolutePath
+val bindingsDir = file("src/main/java").absolutePath
+
+val cargoHome: String = System.getenv("CARGO_HOME") ?: "/caches/cargo"
+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 (our build container redirects it to a
+// cache mount because the container's writable rootfs hits 100% before
+// the cross-compile for 4 ABIs finishes). Falls back to the default
+// `/target`.
+val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR")
+ ?: "$rustRoot/target"
+
+val cargoBuild by tasks.registering(Exec::class) {
+ group = "rust"
+ description = "Cross-compile strawcore for all Android ABIs via cargo-ndk."
+ workingDir = file(rustRoot)
+ environment("ANDROID_NDK_HOME", ndkHome)
+ environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
+ commandLine = listOf(
+ cargoBin, "ndk",
+ "-t", "arm64-v8a",
+ "-t", "armeabi-v7a",
+ "-t", "x86",
+ "-t", "x86_64",
+ "-o", jniLibsDir,
+ "build", "--release", "-p", "strawcore",
+ )
+ standardOutput = System.out
+ errorOutput = System.err
+}
+
+val cargoBuildHost by tasks.registering(Exec::class) {
+ group = "rust"
+ description = "Build host-arch debug strawcore so bindgen can read its UniFFI metadata."
+ workingDir = file(rustRoot)
+ environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
+ commandLine = listOf(cargoBin, "build", "-p", "strawcore")
+ standardOutput = System.out
+ errorOutput = System.err
+}
+
+val uniffiBindgen by tasks.registering(Exec::class) {
+ group = "rust"
+ description = "Generate Kotlin bindings for strawcore via uniffi-bindgen."
+ dependsOn(cargoBuildHost)
+ workingDir = file(rustRoot)
+ environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
+ commandLine = listOf(
+ cargoBin, "run", "--quiet", "--bin", "uniffi-bindgen", "--",
+ "generate",
+ "--library", "$cargoTargetDir/debug/libstrawcore.so",
+ "--crate", "strawcore",
+ "--language", "kotlin",
+ "--no-format",
+ "--out-dir", bindingsDir,
+ )
+ standardOutput = System.out
+ errorOutput = System.err
+}
+
+// Make sure Android's JNI-libs merge picks up the freshly built .so files,
+// and Kotlin compilation can resolve the generated bindings.
+tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
+ .configureEach { dependsOn(cargoBuild) }
+tasks.matching { it.name.startsWith("compile") && it.name.endsWith("Kotlin") }
+ .configureEach { dependsOn(uniffiBindgen) }
diff --git a/strawApp/proguard-rules.pro b/strawApp/proguard-rules.pro
new file mode 100644
index 000000000..fad37e118
--- /dev/null
+++ b/strawApp/proguard-rules.pro
@@ -0,0 +1,90 @@
+# SPDX-FileCopyrightText: 2026 Sulkta-Coop
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# R8 keep rules for the Straw app module. The legacy `app/proguard-rules.pro`
+# is for the upstream NewPipe module — different namespaces, different
+# rules. This file is OURS.
+#
+# AGP's getDefaultProguardFile("proguard-android-optimize.txt") handles
+# the Android framework + AndroidX + Compose runtime defaults via
+# consumer rules shipped with each library. We only need to spell out
+# what those defaults can't see:
+#
+# * UniFFI bindings — reflective FFI dispatch from generated code.
+# * JNA — reflects on every class extending com.sun.jna.Library
+# (that's how the loadLibrary glue works).
+# * Our kotlinx-serialization @Serializable types — their generated
+# $$serializer companions get tree-shaken without explicit keeps.
+# * Media3 session metadata Parcelables.
+
+# -- UniFFI -------------------------------------------------------------
+# Generated bindings live under uniffi.strawcore.*. The Rust side calls
+# them via JNI symbol name; if R8 renames the class or methods, every
+# extractor call NPEs.
+-keep class uniffi.strawcore.** { *; }
+-keep class uniffi.** { *; }
+
+# -- JNA ---------------------------------------------------------------
+# JNA looks up Library subclasses by Class.forName + reflection at
+# load time. Anything that extends Library or has @FieldOrder must
+# survive.
+-keep class * extends com.sun.jna.Library { *; }
+-keep class com.sun.jna.** { *; }
+-dontwarn com.sun.jna.**
+
+# -- kotlinx-serialization ---------------------------------------------
+# Every @Serializable type gets a synthetic Companion + $$serializer
+# class. R8 will strip the $$serializer if nothing visibly calls it
+# (the lookup goes through reflection on the Companion).
+-keepattributes *Annotation*, InnerClasses
+-dontwarn kotlinx.serialization.**
+
+-keep,includedescriptorclasses class com.sulkta.straw.**$$serializer { *; }
+-keepclassmembers class com.sulkta.straw.** {
+ *** Companion;
+}
+-keepclasseswithmembers class com.sulkta.straw.** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+
+# Same dance for our top-level @Serializable types defined outside
+# `com.sulkta.straw.**` (Rust DTOs, etc.). Belt + suspenders.
+-keepclassmembers @kotlinx.serialization.Serializable class * {
+ static **$Companion Companion;
+ public static <1>$Companion Companion;
+}
+-keepclasseswithmembers @kotlinx.serialization.Serializable class * {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+-keep class **$$serializer { *; }
+
+# -- Media3 / ExoPlayer ------------------------------------------------
+# Most of Media3 ships consumer rules but session-related Parcelables
+# are reflectively reconstructed across process boundaries (the
+# MediaController talks to PlaybackService via Binder). Keep their
+# field names.
+-keep class androidx.media3.session.** { *; }
+-keep class androidx.media3.common.MediaItem { *; }
+-keep class androidx.media3.common.MediaItem$* { *; }
+-keep class androidx.media3.common.MediaMetadata { *; }
+
+# -- Strawcore exceptions / DTOs reflected by UniFFI --------------------
+# StrawcoreError is a sealed Throwable hierarchy exposed via UniFFI.
+# Keep all subclasses + their fields so the Kotlin pattern-match works
+# after minification.
+-keep class com.sulkta.straw.feature.player.** { *; }
+
+# -- Reflection-via-Class.forName paths from Compose --------------------
+# Compose's runtime does some Class.forName for its own bootstrap; the
+# AGP consumer rules cover this, but documenting the dependency here
+# so a future bump doesn't surprise us.
+-keep class androidx.compose.runtime.** { *; }
+
+# -- WorkManager Worker classes ----------------------------------------
+# WorkManager instantiates Worker subclasses by class name via
+# reflection (`Class.forName(workerSpec.workerClassName)`). If R8
+# renames our UpdateCheckWorker the scheduler enqueues it but the
+# instantiation fails silently and no checks ever run.
+-keep class com.sulkta.straw.feature.update.UpdateCheckWorker { *; }
+-keep class com.sulkta.straw.feature.feed.FeedRefreshWorker { *; }
+-keep class * extends androidx.work.ListenableWorker { *; }
diff --git a/strawApp/src/main/AndroidManifest.xml b/strawApp/src/main/AndroidManifest.xml
index 46abc05b7..56adc1f94 100644
--- a/strawApp/src/main/AndroidManifest.xml
+++ b/strawApp/src/main/AndroidManifest.xml
@@ -11,12 +11,20 @@
+
+
+
-
+
@@ -39,6 +52,9 @@
+
+
+
@@ -47,11 +63,11 @@
-
+
+
+
+
+
+
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt
index 02396749c..1b57d3471 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Tiny in-app nav model — sealed Screen + a stack. No nav library; pure
- * state. Good enough for day-2's home → search → detail → player flow.
+ * state.
*/
package com.sulkta.straw
@@ -16,9 +16,12 @@ sealed interface Screen {
data object Home : Screen
data object Search : Screen
data object Settings : Screen
+ data object Playlists : Screen
+ data object Downloads : Screen
data class VideoDetail(val streamUrl: String, val title: String) : Screen
data class Player(val streamUrl: String, val title: String) : Screen
data class Channel(val channelUrl: String, val name: String) : Screen
+ data class PlaylistView(val playlistId: String, val name: String) : Screen
}
class Navigator(initial: Screen) {
@@ -29,12 +32,27 @@ class Navigator(initial: Screen) {
stack.add(s)
}
- /** @return false if we couldn't pop (root), true otherwise. */
+ /**
+ * Pop the current screen off the stack. Returns false at root so the
+ * caller can defer to the system back behavior (exit the app); true
+ * otherwise.
+ */
fun pop(): Boolean {
if (stack.size <= 1) return false
stack.removeAt(stack.lastIndex)
return true
}
+
+ /**
+ * Replace the entire stack with a single screen. Used by the
+ * swipe-to-minimize gesture when the user lands directly on a video
+ * page via a deep link — there's nothing to pop back to, so we drop
+ * them on Home instead.
+ */
+ fun resetTo(s: Screen) {
+ stack.clear()
+ stack.add(s)
+ }
}
@Composable
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
index 670db54e3..4f9e1616f 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt
@@ -12,29 +12,54 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.media3.common.util.UnstableApi
+import com.sulkta.straw.data.Settings
+import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.channel.ChannelScreen
import com.sulkta.straw.feature.detail.VideoDetailScreen
+import com.sulkta.straw.feature.download.DownloadsScreen
+import com.sulkta.straw.feature.player.LocalStrawController
+import com.sulkta.straw.feature.player.MinibarOverlay
+import com.sulkta.straw.feature.player.NowPlaying
import com.sulkta.straw.feature.player.PlayerScreen
+import com.sulkta.straw.feature.player.SponsorBlockSkipLoop
+import com.sulkta.straw.feature.player.rememberStrawController
+import com.sulkta.straw.feature.playlist.PlaylistViewScreen
+import com.sulkta.straw.feature.playlist.PlaylistsScreen
import com.sulkta.straw.feature.search.SearchScreen
import com.sulkta.straw.feature.settings.SettingsScreen
+import kotlinx.coroutines.flow.MutableStateFlow
-private val YT_HOSTS = setOf(
- "youtube.com", "www.youtube.com", "m.youtube.com",
- "music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com",
- "youtu.be",
-)
+// Allowlist now lives in util/YtUrl.kt with extra hardening (scheme
+// 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_\\-]+)",
)
class StrawActivity : ComponentActivity() {
+
+ /**
+ * Newly-arrived deep-link URL while the activity is already running.
+ * `onNewIntent` writes here; the Compose tree observes and pushes a
+ * VideoDetail screen. Without this the singleTask flag silently drops
+ * every share-to-Straw after the first.
+ */
+ private val pendingDeepLink = MutableStateFlow(null)
+
+ @OptIn(UnstableApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -42,8 +67,22 @@ class StrawActivity : ComponentActivity() {
val startUrl = pickYouTubeUrl(intent)
setContent {
- val scheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
+ // Theme picker: System follows OS, Light/Dark force the
+ // matching scheme regardless of system setting.
+ val themeMode by Settings.get().themeMode.collectAsState()
+ val systemDark = isSystemInDarkTheme()
+ val dark = when (themeMode) {
+ ThemeMode.System -> systemDark
+ ThemeMode.Light -> false
+ ThemeMode.Dark -> true
+ }
+ val scheme = if (dark) strawDarkColors() else strawLightColors()
+ // One MediaController for the whole activity. Every screen pulls
+ // it via LocalStrawController; the minibar overlay below uses it
+ // too. Single player, single source of truth.
+ val controller = rememberStrawController()
MaterialTheme(colorScheme = scheme) {
+ CompositionLocalProvider(LocalStrawController provides controller) {
Surface(modifier = Modifier.fillMaxSize()) {
val initial: Screen =
if (startUrl != null) Screen.VideoDetail(startUrl, "") else Screen.Home
@@ -62,71 +101,126 @@ class StrawActivity : ComponentActivity() {
onDispose { cb.remove() }
}
- when (val s = nav.current) {
- is Screen.Home -> StrawHome(
- onOpenSearch = { nav.push(Screen.Search) },
- onOpenSettings = { nav.push(Screen.Settings) },
- onOpenVideo = { url, title ->
- nav.push(Screen.VideoDetail(url, title))
- },
- onOpenChannel = { url, name ->
- nav.push(Screen.Channel(url, name))
- },
- )
- is Screen.Settings -> SettingsScreen()
- is Screen.Search -> SearchScreen(
- onOpenVideo = { url, title ->
- nav.push(Screen.VideoDetail(url, title))
- },
- )
- is Screen.VideoDetail -> VideoDetailScreen(
- streamUrl = s.streamUrl,
- initialTitle = s.title,
- onPlay = {
- nav.push(Screen.Player(s.streamUrl, s.title))
- },
- onOpenChannel = { url, name ->
- nav.push(Screen.Channel(url, name))
- },
- onOpenVideo = { url, title ->
- nav.push(Screen.VideoDetail(url, title))
- },
- )
- is Screen.Channel -> ChannelScreen(
- channelUrl = s.channelUrl,
- initialName = s.name,
- onOpenVideo = { url, title ->
- nav.push(Screen.VideoDetail(url, title))
- },
- )
- is Screen.Player -> PlayerScreen(
- streamUrl = s.streamUrl,
- title = s.title,
- )
+ // Drain newly-arrived deep links. Consumed (cleared) once
+ // pushed so we don't re-navigate on every recomposition.
+ val pending by pendingDeepLink.collectAsState()
+ LaunchedEffect(pending) {
+ val url = pending ?: return@LaunchedEffect
+ nav.push(Screen.VideoDetail(url, ""))
+ pendingDeepLink.value = null
+ }
+
+ // SponsorBlock skip loop runs at the activity level so it
+ // applies whether the user is fullscreen, in the minibar,
+ // or away from the player surface.
+ SponsorBlockSkipLoop()
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ ScreenContent(nav, s = nav.current)
+ // The minibar is the takeover-when-you-leave UI:
+ // hide it while you're on the actual video page
+ // (the inline player IS the player) and hide it
+ // in fullscreen (which IS the player). Everywhere
+ // else, audio keeps going and the minibar gives
+ // you a way back.
+ val cur = nav.current
+ if (cur !is Screen.Player && cur !is Screen.VideoDetail) {
+ MinibarOverlay(
+ onExpand = {
+ val item = NowPlaying.current.value ?: return@MinibarOverlay
+ nav.push(Screen.VideoDetail(item.streamUrl, item.title))
+ },
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
}
}
+ }
}
}
}
- /** Pull a YouTube URL out of an incoming Intent (VIEW or SEND). */
+ /**
+ * `launchMode="singleTask"` means a fresh VIEW/SEND from Chrome lands
+ * on the already-running activity instead of creating a new instance.
+ * Forward the URL into the Compose tree via the pending-link flow.
+ */
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ pickYouTubeUrl(intent)?.let { pendingDeepLink.value = it }
+ }
+
+ @Composable
+ private fun ScreenContent(nav: Navigator, s: Screen) {
+ when (s) {
+ is Screen.Home -> StrawHome(
+ onOpenSearch = { nav.push(Screen.Search) },
+ onOpenSettings = { nav.push(Screen.Settings) },
+ onOpenPlaylists = { nav.push(Screen.Playlists) },
+ onOpenDownloads = { nav.push(Screen.Downloads) },
+ onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
+ onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
+ )
+ is Screen.Downloads -> DownloadsScreen()
+ is Screen.Settings -> SettingsScreen()
+ is Screen.Search -> SearchScreen(
+ onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
+ onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
+ )
+ is Screen.VideoDetail -> VideoDetailScreen(
+ streamUrl = s.streamUrl,
+ initialTitle = s.title,
+ onPlay = { nav.push(Screen.Player(s.streamUrl, s.title)) },
+ onMinimize = { if (!nav.pop()) nav.resetTo(Screen.Home) },
+ onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
+ onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
+ )
+ is Screen.Channel -> ChannelScreen(
+ channelUrl = s.channelUrl,
+ initialName = s.name,
+ onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
+ )
+ is Screen.Player -> PlayerScreen(
+ streamUrl = s.streamUrl,
+ title = s.title,
+ onMinimize = { nav.pop() },
+ )
+ is Screen.Playlists -> PlaylistsScreen(
+ onOpenPlaylist = { id, name -> nav.push(Screen.PlaylistView(id, name)) },
+ )
+ is Screen.PlaylistView -> PlaylistViewScreen(
+ playlistId = s.playlistId,
+ initialName = s.name,
+ onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) },
+ )
+ }
+ }
+
+ /** Pull a YouTube URL out of an incoming VIEW or SEND intent. */
private fun pickYouTubeUrl(intent: Intent?): String? {
intent ?: return null
return when (intent.action) {
Intent.ACTION_VIEW -> {
val data = intent.data?.toString() ?: return null
// Explicit scheme + host check — defense in depth vs the
- // manifest intent-filter (apps can synth intents that
- // bypass filter scheme matching when activity is exported).
- if (intent.scheme?.lowercase() !in setOf("https", "http")) return null
+ // manifest intent-filter; apps can synth intents that
+ // bypass filter scheme matching on exported activities.
+ // HTTPS only — matches the manifest VIEW filter so an explicit
+ // ComponentName intent can't smuggle an http:// URL past the
+ // filter check. Defense in depth; the YT_URL_RE still allows
+ // http for the ACTION_SEND substring case where the URL is
+ // embedded in attacker-controlled text and we want to match
+ // common share-sheet links, but VIEW must be tighter.
+ if (intent.scheme?.lowercase() != "https") return null
if (!looksLikeYouTube(data)) return null
data
}
Intent.ACTION_SEND -> {
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null
- // Regex extracts a YT-looking substring from arbitrary
- // attacker-controlled text. Re-validate via URI parse + host
- // check before we hand it to NewPipeExtractor.
+ // Extract a YT-looking substring from attacker-controlled
+ // text, then re-validate via URI parse + host check before
+ // handing it to the extractor.
val candidate = YT_URL_RE.find(shared)?.value ?: return null
val truncated = candidate.substringBefore('#').trim()
if (!looksLikeYouTube(truncated)) return null
@@ -136,8 +230,6 @@ class StrawActivity : ComponentActivity() {
}
}
- private fun looksLikeYouTube(url: String): Boolean {
- val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return false
- return host.lowercase() in YT_HOSTS
- }
+ private fun looksLikeYouTube(url: String): Boolean =
+ com.sulkta.straw.util.isAllowedYtUrl(url)
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
index 6d8a1defc..585fefc00 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt
@@ -6,24 +6,98 @@
package com.sulkta.straw
import android.app.Application
+import com.sulkta.straw.data.FeedCache
+import com.sulkta.straw.data.FeedEnrichment
import com.sulkta.straw.data.History
+import com.sulkta.straw.data.Playlists
+import com.sulkta.straw.data.Resume
+import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
-import com.sulkta.straw.extractor.NewPipeDownloader
-import org.schabi.newpipe.extractor.NewPipe
-import org.schabi.newpipe.extractor.localization.ContentCountry
-import org.schabi.newpipe.extractor.localization.Localization
+import com.sulkta.straw.feature.dataimport.SettingsImport
+import com.sulkta.straw.feature.feed.FeedRefreshScheduler
+import com.sulkta.straw.feature.update.UpdateScheduler
+import com.sulkta.straw.feature.update.runUpdateCheck
+import com.sulkta.straw.util.strawLogW
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
class StrawApp : Application() {
+ /**
+ * App-scoped coroutine scope for one-time startup work that
+ * shouldn't tie up Application.onCreate. SupervisorJob so a failure
+ * 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).
+ */
+ private val appScope = CoroutineScope(
+ SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, t ->
+ strawLogW("StrawApp") { "appScope uncaught: ${t.javaClass.simpleName}: ${t.message}" }
+ },
+ )
+
+ companion object {
+ /** Process-scoped coroutine scope — survives Composition + ViewModel
+ * teardown. Use for fire-and-forget work like long-press
+ * "Add to queue" that needs to outlive the UI surface that
+ * triggered it. */
+ lateinit var globalScope: CoroutineScope
+ private set
+ }
+
+ init {
+ // The companion lateinit guarantees the same StrawApp instance
+ // is the only one that sets globalScope — Application is a
+ // process-singleton on Android.
+ globalScope = appScope
+ }
+
override fun onCreate() {
super.onCreate()
- NewPipe.init(
- NewPipeDownloader.init(),
- Localization("en", "US"),
- ContentCountry("US"),
- )
- History.init(this)
+ // Path C-7: route Rust `log::*` calls into Android logcat under tag
+ // "strawcore". Without this, every log line emitted from rustypipe /
+ // strawcore is silently dropped, making playback regressions invisible
+ // from `adb logcat`.
+ uniffi.strawcore.initLogging()
+ // Small + universally-accessed stores: synchronous init.
+ // Settings is a handful of SP keys (read on first compose for
+ // themeMode), History caps at 50 watches + 20 searches,
+ // Subscriptions is a single channel list — sub-millisecond
+ // cost on cold cache.
Settings.init(this)
+ History.init(this)
Subscriptions.init(this)
+ Playlists.init(this)
+ Resume.init(this)
+ FeedEnrichment.init(this)
+ // 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
+ // decode that goes with it) is lazy. ViewModels accessing
+ // these on IO trigger the construction there — never on the
+ // main thread.
+ FeedCache.init(this)
+ SearchCache.init(this)
+ // sweepStale's deleteRecursively
+ // can walk ~256 MB if a previous import was LMK-killed
+ // mid-extraction. Strictly off the main thread.
+ appScope.launch {
+ SettingsImport.sweepStale(this@StrawApp)
+ }
+ // Auto-update polling. Schedule the periodic worker if enabled,
+ // then kick a fresh check on cold start so users don't wait a
+ // full interval to find out about a pending update.
+ UpdateScheduler.applyFromSettings(this)
+ if (Settings.get().autoUpdateCheck.value) {
+ appScope.launch { runUpdateCheck(this@StrawApp) }
+ }
+ // Background subs feed refresh — opt-in periodic WorkManager
+ // job that pre-warms FeedCache so cold open paints fresh.
+ FeedRefreshScheduler.applyFromSettings(this)
}
}
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
index 31e1f2452..d62c68852 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
@@ -8,8 +8,12 @@
package com.sulkta.straw
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -22,13 +26,22 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.PlaylistPlay
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -36,23 +49,23 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -67,7 +80,11 @@ import com.sulkta.straw.data.History
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
+import com.sulkta.straw.feature.player.VideoThumbnail
+import com.sulkta.straw.feature.playlist.VideoActionTarget
+import com.sulkta.straw.feature.playlist.VideoActionsSheet
import com.sulkta.straw.feature.search.StreamItem
+import com.sulkta.straw.util.rememberBottomContentPadding
import com.sulkta.straw.util.formatViews
import kotlinx.coroutines.launch
@@ -78,6 +95,8 @@ private enum class HomeView { Subs, History }
fun StrawHome(
onOpenSearch: () -> Unit,
onOpenSettings: () -> Unit,
+ onOpenPlaylists: () -> Unit,
+ onOpenDownloads: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
feedVm: SubscriptionFeedViewModel = viewModel(),
@@ -107,7 +126,7 @@ fun StrawHome(
NavigationDrawerItem(
label = { Text("Subscriptions") },
- icon = { Text("👤") },
+ icon = { Icon(Icons.Filled.Person, contentDescription = null) },
selected = view == HomeView.Subs,
onClick = {
view = HomeView.Subs
@@ -117,7 +136,7 @@ fun StrawHome(
)
NavigationDrawerItem(
label = { Text("History") },
- icon = { Text("📺") },
+ icon = { Icon(Icons.Filled.History, contentDescription = null) },
selected = view == HomeView.History,
onClick = {
view = HomeView.History
@@ -125,10 +144,30 @@ fun StrawHome(
},
modifier = Modifier.padding(horizontal = 12.dp),
)
+ NavigationDrawerItem(
+ label = { Text("Playlists") },
+ icon = { Icon(Icons.Filled.PlaylistPlay, contentDescription = null) },
+ selected = false,
+ onClick = {
+ scope.launch { drawerState.close() }
+ onOpenPlaylists()
+ },
+ modifier = Modifier.padding(horizontal = 12.dp),
+ )
+ NavigationDrawerItem(
+ label = { Text("Downloads") },
+ icon = { Icon(Icons.Filled.Download, contentDescription = null) },
+ selected = false,
+ onClick = {
+ scope.launch { drawerState.close() }
+ onOpenDownloads()
+ },
+ modifier = Modifier.padding(horizontal = 12.dp),
+ )
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
NavigationDrawerItem(
label = { Text("Settings") },
- icon = { Text("⚙") },
+ icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
@@ -141,42 +180,33 @@ fun StrawHome(
) {
Scaffold(
topBar = {
+ // Green-tinted bar inspired by NewPipe/Tubular's colored
+ // header, but using our forest-green primary container so
+ // it sits cleanly with the rest of the Material 3 surfaces.
TopAppBar(
title = {
- // Search-pill in the title slot — tap takes you to the
- // full search screen with the field auto-focused. Same
- // idea as YT's mobile top bar.
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .padding(end = 8.dp)
- .height(40.dp)
- .clip(RoundedCornerShape(20.dp))
- .clickable(onClick = onOpenSearch),
- color = MaterialTheme.colorScheme.surfaceVariant,
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(horizontal = 14.dp),
- ) {
- Text(
- "🔍",
- style = MaterialTheme.typography.bodyMedium,
- )
- Spacer(modifier = Modifier.width(10.dp))
- Text(
- "Search YouTube",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
+ Text(
+ "straw",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
},
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = "Menu")
}
},
+ actions = {
+ IconButton(onClick = onOpenSearch) {
+ Icon(Icons.Filled.Search, contentDescription = "Search")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = MaterialTheme.colorScheme.onPrimary,
+ navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
+ actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
+ ),
)
},
) { padding ->
@@ -208,6 +238,10 @@ fun StrawHome(
@Composable
private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
val watches by History.get().watches.collectAsState()
+ var actionTarget by remember { mutableStateOf(null) }
+ actionTarget?.let { t ->
+ VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
+ }
Column {
Text(
@@ -224,9 +258,20 @@ private fun HistoryPane(onOpenVideo: (url: String, title: String) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
- LazyColumn {
+ LazyColumn(contentPadding = rememberBottomContentPadding()) {
items(watches) { w ->
- RecentRow(w) { onOpenVideo(w.url, w.title) }
+ RecentRow(
+ item = w,
+ onClick = { onOpenVideo(w.url, w.title) },
+ onLongClick = {
+ actionTarget = VideoActionTarget(
+ streamUrl = w.url,
+ title = w.title,
+ uploader = w.uploader,
+ thumbnail = w.thumbnail,
+ )
+ },
+ )
HorizontalDivider()
}
}
@@ -242,8 +287,49 @@ private fun SubsPane(
) {
val subs by Subscriptions.get().subs.collectAsState()
val feed by feedVm.ui.collectAsState()
+ val watches by History.get().watches.collectAsState()
+ var actionTarget by remember { mutableStateOf(null) }
+ actionTarget?.let { t ->
+ VideoActionsSheet(target = t, onDismiss = { actionTarget = null })
+ }
LaunchedEffect(subs) { feedVm.refreshIfStale() }
+ // Filter + pagination state. hideWatched is sticky for the session
+ // (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.
+ var hideWatched by remember { mutableStateOf(false) }
+ var visibleCount by remember { mutableIntStateOf(PAGE_SIZE) }
+
+ // O(1) lookup for the watched-filter; rebuild only when watches
+ // change. Drop blank IDs — `recordWatch` doesn't gate on those,
+ // and a blank in the set would `extractVideoId(url)=""` match
+ // EVERY malformed-URL item and silently hide them all.
+ val watchedIds = remember(watches) {
+ watches.map { it.videoId }.filter { it.isNotBlank() }.toSet()
+ }
+
+ val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState()
+ val filteredItems = remember(feed.items, hideWatched, watchedIds, hideShorts) {
+ val watchFiltered = if (!hideWatched) feed.items
+ else feed.items.filterNot { extractVideoId(it.url) in watchedIds }
+ com.sulkta.straw.util.applyContentFilters(watchFiltered, hideShorts = hideShorts)
+ }
+ // Reset pagination when the underlying list changes so the user
+ // doesn't end up looking at "no more items" after a refresh.
+ LaunchedEffect(filteredItems) {
+ if (visibleCount > filteredItems.size.coerceAtLeast(PAGE_SIZE)) {
+ visibleCount = PAGE_SIZE
+ }
+ }
+ // remember the page-slice so we don't allocate a new ArrayList on
+ // every recomposition (scroll hitch).
+ val displayed = remember(filteredItems, visibleCount) {
+ filteredItems.take(visibleCount)
+ }
+ val hasMore = filteredItems.size > visibleCount
+
Column {
if (subs.isEmpty()) {
Text(
@@ -267,6 +353,13 @@ private fun SubsPane(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
+ FilterChip(
+ selected = hideWatched,
+ onClick = { hideWatched = !hideWatched },
+ label = { Text("Hide watched") },
+ colors = FilterChipDefaults.filterChipColors(),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = { feedVm.refresh() }) {
Text(if (feed.loading) "..." else "Refresh")
}
@@ -280,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(
@@ -309,34 +402,127 @@ private fun SubsPane(
color = MaterialTheme.colorScheme.error,
)
}
+ feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
+ Text(
+ "All caught up — nothing unwatched.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
else -> {
- LazyColumn {
- items(feed.items) { item ->
- FeedRow(item) { onOpenVideo(item.url, item.title) }
+ val listState = rememberLazyListState()
+ // Bump visibleCount when the user scrolls within 5 items
+ // of the current bottom. snapshotFlow + derivedStateOf
+ // keeps this off the per-frame recompose path.
+ val nearBottom by remember {
+ derivedStateOf {
+ val info = listState.layoutInfo
+ val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
+ lastVisible >= info.totalItemsCount - 5
+ }
+ }
+ // Key on listState only — the previous key set
+ // (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 audit.
+ //
+ // hasMore and filteredItems are read inside the
+ // snapshotFlow producer (not closed over from outside)
+ // so Compose re-reads them on each frame instead of
+ // capturing the stale value at lambda-creation time.
+ val filteredCount = filteredItems.size
+ LaunchedEffect(listState, filteredCount) {
+ snapshotFlow {
+ nearBottom && visibleCount < filteredCount
+ }.collect { shouldGrow ->
+ if (shouldGrow) {
+ visibleCount = (visibleCount + PAGE_SIZE)
+ .coerceAtMost(filteredCount)
+ }
+ }
+ }
+ LazyColumn(
+ state = listState,
+ contentPadding = rememberBottomContentPadding(),
+ ) {
+ items(
+ items = displayed,
+ key = { it.url },
+ ) { item ->
+ FeedRow(
+ item = item,
+ onClick = { onOpenVideo(item.url, item.title) },
+ onLongClick = {
+ actionTarget = VideoActionTarget(
+ streamUrl = item.url,
+ title = item.title,
+ uploader = item.uploader,
+ thumbnail = item.thumbnail,
+ )
+ },
+ )
HorizontalDivider()
}
+ if (hasMore) {
+ item {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ "Loading more...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
}
}
}
}
}
+private const val PAGE_SIZE = 20
+
+/**
+ * Extract the YouTube video ID from a watch URL so we can cross-check
+ * against History.watches (which stores videoId, not full URL). Handles
+ * the common forms: youtube.com/watch?v=XXXXXXXXXXX and youtu.be/X...
+ * Returns empty string when nothing matches — callers compare against
+ * watchedIds, so an empty string just won't filter anything out.
+ */
+private val VIDEO_ID_RE = Regex("(?:v=|/)([A-Za-z0-9_-]{11})(?:[?].*)?$")
+private fun extractVideoId(url: String): String =
+ VIDEO_ID_RE.find(url)?.groupValues?.getOrNull(1).orEmpty()
+
+@OptIn(ExperimentalFoundationApi::class)
@Composable
-private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
+private fun FeedRow(
+ item: StreamItem,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+) {
Row(
modifier = Modifier
.fillMaxWidth()
- .clickable(onClick = onClick)
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top,
) {
- AsyncImage(
- model = item.thumbnail,
- contentDescription = null,
+ VideoThumbnail(
+ thumbnail = item.thumbnail,
+ videoUrl = item.url,
+ durationSeconds = item.durationSeconds,
modifier = Modifier
.width(140.dp)
- .height(80.dp)
- .clip(RoundedCornerShape(6.dp)),
+ .height(80.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
@@ -355,6 +541,10 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
append(" · ")
append(formatViews(item.viewCount))
}
+ if (item.uploadDateRelative.isNotBlank()) {
+ append(" · ")
+ append(item.uploadDateRelative)
+ }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -376,37 +566,71 @@ private fun SubChip(
.clickable { onOpenChannel(ch.url, ch.name) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
- AsyncImage(
- model = ch.avatar,
- contentDescription = null,
- modifier = Modifier.size(56.dp).clip(CircleShape),
- )
+ if (ch.avatar.isNullOrBlank()) {
+ // Lettered fallback — strawcore can return a null avatar
+ // when the channel header layout doesn't include one (more
+ // common on smaller channels). Feed-fetch backfills this
+ // asynchronously via Subscriptions.updateAvatar, but until
+ // it arrives we still want SOMETHING visible.
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = ch.name.firstOrNull()?.uppercase().orEmpty(),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ } else {
+ AsyncImage(
+ model = ch.avatar,
+ contentDescription = null,
+ modifier = Modifier.size(56.dp).clip(CircleShape),
+ )
+ }
Spacer(modifier = Modifier.height(4.dp))
+ // Single line + ellipsis instead of maxLines=2. The 80dp chip
+ // 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.
Text(
text = ch.name,
style = MaterialTheme.typography.labelSmall,
- maxLines = 2,
+ maxLines = 1,
overflow = TextOverflow.Ellipsis,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
)
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
-private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
+private fun RecentRow(
+ item: WatchHistoryItem,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+) {
Row(
modifier = Modifier
.fillMaxWidth()
- .clickable(onClick = onClick)
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- AsyncImage(
- model = item.thumbnail,
- contentDescription = null,
+ VideoThumbnail(
+ thumbnail = item.thumbnail,
+ videoUrl = item.url,
+ durationSeconds = 0L,
modifier = Modifier
.width(120.dp)
- .height(68.dp)
- .clip(RoundedCornerShape(6.dp)),
+ .height(68.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt
new file mode 100644
index 000000000..85fa21b09
--- /dev/null
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Sulkta-Coop
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * Straw palette pulled directly from sulkta.com's stylesheet:
+ * #4ade80 primary green (Tailwind green-400, most-used on the site)
+ * #166534 deep green (green-800, headings + emphasis)
+ * #22c55e mid green (green-500, links + buttons)
+ * #86efac light green container (green-300)
+ * #e8f5e8 pale green tint
+ * #d97706 amber accent (sulkta.com calls this out for chips)
+ * #374137 olive-gray secondary
+ * #0a0a0a near-black text on light
+ * #111411 near-black with green tint for dark surface
+ *
+ * Mapped into Material 3's primary / secondary / tertiary tonal roles
+ * so all the derived M3 surfaces (containers, outlines, etc.) follow.
+ */
+
+package com.sulkta.straw
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.ui.graphics.Color
+
+// Light theme — primary is sulkta.com's deep green (#166534), strong
+// enough for white text and matches the site's heading emphasis.
+private val LPrimary = Color(0xFF166534)
+private val LOnPrimary = Color(0xFFFFFFFF)
+private val LPrimaryContainer = Color(0xFF86EFAC)
+private val LOnPrimaryContainer = Color(0xFF0A0A0A)
+private val LSecondary = Color(0xFF374137)
+private val LOnSecondary = Color(0xFFFFFFFF)
+private val LSecondaryContainer = Color(0xFFE8F5E8)
+private val LOnSecondaryContainer = Color(0xFF0A0A0A)
+private val LTertiary = Color(0xFFD97706)
+private val LOnTertiary = Color(0xFFFFFFFF)
+
+// Dark theme — primary is sulkta.com's bright lime (#4ade80) since dark
+// backgrounds need a brighter accent for readability. PrimaryContainer
+// is the deep green so emphasis stays consistent across themes.
+private val DPrimary = Color(0xFF4ADE80)
+private val DOnPrimary = Color(0xFF0A0A0A)
+private val DPrimaryContainer = Color(0xFF166534)
+private val DOnPrimaryContainer = Color(0xFF86EFAC)
+private val DSecondary = Color(0xFF9AB89A)
+private val DOnSecondary = Color(0xFF111411)
+private val DSecondaryContainer = Color(0xFF374137)
+private val DOnSecondaryContainer = Color(0xFFE8F5E8)
+private val DTertiary = Color(0xFFD97706)
+private val DOnTertiary = Color(0xFF0A0A0A)
+
+fun strawLightColors(): ColorScheme = lightColorScheme(
+ primary = LPrimary,
+ onPrimary = LOnPrimary,
+ primaryContainer = LPrimaryContainer,
+ onPrimaryContainer = LOnPrimaryContainer,
+ secondary = LSecondary,
+ onSecondary = LOnSecondary,
+ secondaryContainer = LSecondaryContainer,
+ onSecondaryContainer = LOnSecondaryContainer,
+ tertiary = LTertiary,
+ onTertiary = LOnTertiary,
+)
+
+fun strawDarkColors(): ColorScheme = darkColorScheme(
+ primary = DPrimary,
+ onPrimary = DOnPrimary,
+ primaryContainer = DPrimaryContainer,
+ onPrimaryContainer = DOnPrimaryContainer,
+ secondary = DSecondary,
+ onSecondary = DOnSecondary,
+ secondaryContainer = DSecondaryContainer,
+ onSecondaryContainer = DOnSecondaryContainer,
+ tertiary = DTertiary,
+ onTertiary = DOnTertiary,
+)
+
+// Semi-transparent overlays for chrome (overlay buttons, the SB badge,
+// the inline-player fullscreen pill) and for the dimmed area behind the
+// minibar thumbnail. Kept here so a theme tweak touches one place.
+val OverlayChromeColor = Color(0xCC222222)
+val OverlayDimColor = Color(0xCC000000)
+
+// Watch-progress bar painted across the bottom of a video thumbnail when
+// the user has a saved scrub-point. Solid red foreground over a slightly-
+// dim track. Matches YT / NewPipe conventions so it reads instantly.
+val ProgressBarFillColor = Color(0xFFE53935)
+val ProgressBarTrackColor = Color(0x66000000)
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt
new file mode 100644
index 000000000..597688f1e
--- /dev/null
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt
@@ -0,0 +1,136 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Sulkta-Coop
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * Subs-feed enrichment cache. RSS gives us title/url/thumbnail/date
+ * fast but no view count or duration. After a feed refresh paints
+ * from RSS, SubscriptionFeedViewModel fans out lightweight
+ * uniffi.strawcore.enrichFeedItem() calls for the top visible items
+ * and stashes the results here. mergeFromCache overlays the
+ * enrichment onto each StreamItem at render time so the row shows
+ * 'N views · X duration' once available.
+ *
+ * Storage: SharedPreferences-lite, single JSON blob keyed by videoId.
+ * TTL bound to Settings.cacheTtl so enrichments age out alongside the
+ * rest of the cache. Hard cap at MAX_ENRICHMENTS to bound disk +
+ * memory.
+ */
+
+package com.sulkta.straw.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.updateAndGet
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+@Serializable
+data class Enrichment(
+ val viewCount: Long,
+ val durationSeconds: Long,
+ val fetchedAt: Long,
+)
+
+private const val PREFS = "straw_feed_enrichment"
+private const val KEY = "enrichments_v1"
+
+/**
+ * Hard ceiling — keeps the JSON blob below ~250 KB even at the cap
+ * (50 bytes/entry × 5000 = 250 KB). The user-facing cap doesn't tie
+ * to this; enrichment is "cache" not "user data."
+ */
+private const val MAX_ENRICHMENTS = 5_000
+
+class EnrichmentStore(context: Context) {
+ private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private val json = Json { ignoreUnknownKeys = true }
+
+ private val _entries = MutableStateFlow(load())
+ val entries: StateFlow