diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml
index 7d2273bb5..466a663c2 100644
--- a/.forgejo/workflows/build.yml
+++ b/.forgejo/workflows/build.yml
@@ -81,6 +81,22 @@ jobs:
|| { echo "::error::versionCode auto-patch failed (sed matched nothing)"; exit 1; }
echo "auto versionCode = $VC (commit count $COUNT)"
+ # The unit tests run before the APK is built, so that a failing test
+ # fails the job before anything is built, signed or published. Neither
+ # step has a secret in its environment. Both use the build step's
+ # target dir (off the container rootfs) and its low CPU and I/O priority.
+ - name: Rust unit tests (strawcore)
+ working-directory: straw/rust
+ env:
+ CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
+ run: ionice -c3 nice -n 19 cargo test -p strawcore --lib
+
+ - name: JVM unit tests
+ working-directory: straw
+ env:
+ CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
+ run: ionice -c3 nice -n 19 ./gradlew :strawApp:testDebugUnitTest --no-daemon --stacktrace
+
- name: Assemble debug APK
working-directory: straw
env:
diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs
index ecf7e4015..750c6f567 100644
--- a/rust/strawcore/src/feed.rs
+++ b/rust/strawcore/src/feed.rs
@@ -8,10 +8,19 @@
// 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.
+// only. No duration, no view count, no 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.
+//
+// It does say which entries are Shorts: a Short's
+// `` points at `https://www.youtube.com/shorts/`
+// where every other upload points at `/watch?v=`. Measured 2026-10-22
+// on 7 public channel feeds (91 entries): the 33 entries with a /shorts/
+// link were exactly the channel-feed entries also listed in the channel's
+// Shorts playlist feed (`playlist_id=UUSH...`), and none of the 58
+// /watch links were. parse_rss keeps that signal by giving a Short its
+// /shorts/ URL, which is the shape the app's hide-Shorts filter
+// (ContentFilter.looksLikeShort) already drops. Costs no extra request.
use std::sync::OnceLock;
use std::time::Duration;
@@ -23,6 +32,11 @@ use crate::error::StrawcoreError;
use crate::search::SearchItem;
const RSS_BASE: &str = "https://www.youtube.com/feeds/videos.xml?channel_id=";
+const WATCH_URL_PREFIX: &str = "https://www.youtube.com/watch?v=";
+/// Prefix of a Short's `` in the channel feed, in the
+/// exact form measured (see the header comment). Also the URL a Short is
+/// given, so the app's `/shorts/` URL check recognises it.
+const SHORTS_URL_PREFIX: &str = "https://www.youtube.com/shorts/";
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
@@ -269,6 +283,9 @@ fn parse_rss(body: &str, channel_id: String) -> Option> {
let mut uploader_url = String::new();
let mut thumbnail: Option = None;
let mut published = String::new();
+ // hrefs of the entry's elements, in order;
+ // together they decide Short or not (see agreed_alternate).
+ let mut alternate_hrefs: Vec = Vec::new();
// What text-collecting state we're in. Replaced per element open.
let mut text_target: Option = None;
@@ -287,6 +304,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> {
uploader_url.clear();
thumbnail = None;
published.clear();
+ alternate_hrefs.clear();
}
if !in_entry {
continue;
@@ -317,6 +335,22 @@ fn parse_rss(body: &str, channel_id: String) -> Option> {
}
}
}
+ // is self-closing too
+ // (the only form measured). Other rel values are ignored.
+ if local == "link" {
+ let mut is_alternate = false;
+ let mut href = None;
+ for attr in e.attributes().flatten() {
+ match attr.key.as_ref() {
+ b"rel" => is_alternate = attr.value.as_ref() == b"alternate",
+ b"href" => href = attr.unescape_value().ok().map(|v| v.into_owned()),
+ _ => {}
+ }
+ }
+ if let (true, Some(h)) = (is_alternate, href) {
+ alternate_hrefs.push(h);
+ }
+ }
}
Ok(Event::Text(t)) => {
if !in_entry {
@@ -346,7 +380,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> {
// 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}"),
+ url: entry_url(&video_id, agreed_alternate(&alternate_hrefs)),
title: title.clone(),
uploader: uploader.clone(),
uploader_url: if uploader_url.is_empty() {
@@ -397,6 +431,35 @@ fn parse_rss(body: &str, channel_id: String) -> Option> {
Some(items)
}
+/// The alternate link an entry's URL is decided from. In 326 channel feeds
+/// captured on 2026-10-22 (4,647 entries) every entry carried exactly one.
+/// An entry that carries more decides from them only when they are all the
+/// same href; if they disagree it decides from none (""), so it keeps its
+/// watch URL. Chosen so that a feed contradicting itself can at worst show
+/// a Short, never hide a regular upload: a shown Short is a row the user
+/// scrolls past, a hidden upload is one they never learn about.
+fn agreed_alternate(hrefs: &[String]) -> &str {
+ match hrefs.split_first() {
+ Some((first, rest)) if rest.iter().all(|h| h == first) => first.as_str(),
+ _ => "",
+ }
+}
+
+/// The URL an RSS entry is given. A Short (its alternate link is exactly
+/// `SHORTS_URL_PREFIX` + its own video id) keeps the /shorts/ shape so the
+/// app can hide it; anything else, including a /shorts/ link naming a
+/// different id, gets the canonical watch URL as before.
+fn entry_url(video_id: &str, alternate_href: &str) -> String {
+ let is_short = alternate_href
+ .strip_prefix(SHORTS_URL_PREFIX)
+ .is_some_and(|id| id == video_id);
+ if is_short {
+ format!("{SHORTS_URL_PREFIX}{video_id}")
+ } else {
+ format!("{WATCH_URL_PREFIX}{video_id}")
+ }
+}
+
enum TextTarget {
VideoId,
Title,
@@ -514,3 +577,244 @@ fn local_name(qualified: &[u8]) -> &str {
None => s,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const CHANNEL: &str = "UCuAXFkgsw1L7xaCfnd5JJOw";
+
+ /// Header plus one Short and one long upload, in the shape the public
+ /// channel feed for CHANNEL served on 2026-10-22 (titles shortened,
+ /// description and statistics trimmed).
+ const REAL_SHAPE: &str = r#"
+
+
+ yt:channel:uAXFkgsw1L7xaCfnd5JJOw
+ uAXFkgsw1L7xaCfnd5JJOw
+ Rick Astley
+
+
+ Rick Astley
+ https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw
+
+ 2015-02-01T16:32:30+00:00
+
+ yt:video:E_MGy41IYVw
+ E_MGy41IYVw
+ UCuAXFkgsw1L7xaCfnd5JJOw
+ 38 years on, and the music video still hits
+
+
+ Rick Astley
+ https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw
+
+ 2026-09-20T08:52:27+00:00
+ 2026-10-08T20:00:28+00:00
+
+ 38 years on, and the music video still hits
+
+
+
+
+
+
+ yt:video:PXC_PYeB6F8
+ PXC_PYeB6F8
+ UCuAXFkgsw1L7xaCfnd5JJOw
+ Rick Astley - Angels On My Side (Live at The O2, London, April 2026)
+
+
+ Rick Astley
+ https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw
+
+ 2026-06-26T16:00:07+00:00
+ 2026-09-30T04:12:50+00:00
+
+ Rick Astley - Angels On My Side
+
+
+ Taken from the Reflections Tour.
+
+
+"#;
+
+ /// A minimal entry; `link` is inserted verbatim (empty for none).
+ fn entry(id: &str, link: &str) -> String {
+ format!(
+ "{id}t {id}{link}\
+ 2026-10-01T00:00:00+00:00"
+ )
+ }
+
+ fn feed(entries: &[String]) -> String {
+ format!(
+ "{}",
+ entries.concat()
+ )
+ }
+
+ fn urls(body: &str) -> Vec {
+ parse_rss(body, CHANNEL.to_string())
+ .expect("parse")
+ .into_iter()
+ .map(|i| i.url)
+ .collect()
+ }
+
+ #[test]
+ fn a_short_keeps_its_shorts_url_and_a_long_upload_gets_a_watch_url() {
+ assert_eq!(
+ urls(REAL_SHAPE),
+ vec![
+ "https://www.youtube.com/shorts/E_MGy41IYVw".to_string(),
+ "https://www.youtube.com/watch?v=PXC_PYeB6F8".to_string(),
+ ]
+ );
+ }
+
+ #[test]
+ fn the_rest_of_a_short_entry_parses_as_before() {
+ let items = parse_rss(REAL_SHAPE, CHANNEL.to_string()).expect("parse");
+ let short = &items[0];
+ assert_eq!(short.title, "38 years on, and the music video still hits");
+ assert_eq!(short.uploader, "Rick Astley");
+ assert_eq!(
+ short.thumbnail.as_deref(),
+ Some("https://i2.ytimg.com/vi/E_MGy41IYVw/hqdefault.jpg")
+ );
+ }
+
+ #[test]
+ fn a_shorts_link_naming_another_video_does_not_make_a_short() {
+ let body = feed(&[entry(
+ "AAAAAAAAAA1",
+ r#""#,
+ )]);
+ assert_eq!(
+ urls(&body),
+ vec!["https://www.youtube.com/watch?v=AAAAAAAAAA1"]
+ );
+ }
+
+ #[test]
+ fn only_the_alternate_link_marks_a_short() {
+ let body = feed(&[
+ entry(
+ "AAAAAAAAAA1",
+ r#""#,
+ ),
+ entry(
+ "AAAAAAAAAA2",
+ r#""#,
+ ),
+ ]);
+ assert_eq!(
+ urls(&body),
+ vec![
+ "https://www.youtube.com/watch?v=AAAAAAAAAA1",
+ "https://www.youtube.com/watch?v=AAAAAAAAAA2",
+ ]
+ );
+ }
+
+ #[test]
+ fn an_entry_without_a_link_is_not_marked_by_the_entry_before_it() {
+ // The same id twice is the only case where a link left over from
+ // the previous entry could change the answer, because the check
+ // compares the link against the entry's own id.
+ let body = feed(&[
+ entry(
+ "AAAAAAAAAA1",
+ r#""#,
+ ),
+ entry("AAAAAAAAAA1", ""),
+ ]);
+ assert_eq!(
+ urls(&body),
+ vec![
+ "https://www.youtube.com/shorts/AAAAAAAAAA1",
+ "https://www.youtube.com/watch?v=AAAAAAAAAA1",
+ ]
+ );
+ }
+
+ #[test]
+ fn entry_url_trusts_only_the_measured_shorts_form_for_its_own_id() {
+ let id = "E_MGy41IYVw";
+ assert_eq!(
+ entry_url(id, "https://www.youtube.com/shorts/E_MGy41IYVw"),
+ "https://www.youtube.com/shorts/E_MGy41IYVw"
+ );
+ for other in [
+ "",
+ "https://www.youtube.com/watch?v=E_MGy41IYVw",
+ "https://www.youtube.com/shorts/E_MGy41IYVwX",
+ "https://www.youtube.com/shorts/E_MGy41IYV",
+ "https://www.youtube.com/shorts/E_MGy41IYVw/",
+ "http://www.youtube.com/shorts/E_MGy41IYVw",
+ "https://evil.example/https://www.youtube.com/shorts/E_MGy41IYVw",
+ ] {
+ assert_eq!(
+ entry_url(id, other),
+ "https://www.youtube.com/watch?v=E_MGy41IYVw",
+ "href {other:?} must not mark a Short"
+ );
+ }
+ }
+
+ fn alternate(href: &str) -> String {
+ format!(r#""#)
+ }
+
+ fn shorts(id: &str) -> String {
+ format!("https://www.youtube.com/shorts/{id}")
+ }
+
+ fn watch(id: &str) -> String {
+ format!("https://www.youtube.com/watch?v={id}")
+ }
+
+ #[test]
+ fn an_entity_escaped_shorts_href_still_marks_a_short() {
+ // The href is unescaped as XML before it is compared, so the same
+ // URL written with character references is the same link.
+ let body = feed(&[entry(
+ "AAAAAAAAAA1",
+ r#""#,
+ )]);
+ assert_eq!(urls(&body), vec![shorts("AAAAAAAAAA1")]);
+ }
+
+ #[test]
+ fn alternate_links_that_disagree_keep_the_watch_url() {
+ let a1 = "AAAAAAAAAA1";
+ let a2 = "AAAAAAAAAA2";
+ let a3 = "AAAAAAAAAA3";
+ let a4 = "AAAAAAAAAA4";
+ let body = feed(&[
+ entry(a1, &(alternate(&shorts(a1)) + &alternate(&watch(a1)))),
+ entry(a2, &(alternate(&watch(a2)) + &alternate(&shorts(a2)))),
+ entry(
+ a3,
+ &(alternate(&shorts(a3)) + &alternate(&shorts(a3)) + &alternate(&watch(a3))),
+ ),
+ entry(a4, &(alternate(&shorts(a4)) + &alternate(&shorts(a4)))),
+ ]);
+ assert_eq!(
+ urls(&body),
+ vec![watch(a1), watch(a2), watch(a3), shorts(a4)],
+ "links that disagree decide nothing; links that all agree decide as one"
+ );
+ }
+
+ #[test]
+ fn a_shorts_link_whose_id_differs_only_in_case_is_not_a_short() {
+ // The id is compared exactly. Measured 2026-10-22: YouTube's oEmbed
+ // endpoint answered 200 for E_MGy41IYVw, 404 for e_mgy41iyvw and
+ // 400 for E_MGY41IYVW, so case variants of that id named no video.
+ let body = feed(&[entry("AAAAAAAAAA1", &alternate(&shorts("aaaaaaaaaa1")))]);
+ assert_eq!(urls(&body), vec![watch("AAAAAAAAAA1")]);
+ }
+}
diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts
index 8d13d14cf..1ad275dc5 100644
--- a/strawApp/build.gradle.kts
+++ b/strawApp/build.gradle.kts
@@ -204,6 +204,11 @@ dependencies {
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
// streamInfo / channelInfo to replace NewPipeExtractor.
implementation("net.java.dev.jna:jna:5.14.0@aar")
+
+ // JVM unit tests under src/test, run by `./gradlew :strawApp:testDebugUnitTest`.
+ // Before this line that source set had no test framework, so it was
+ // never compiled or run (the note at the top of LogDumpScrubTest).
+ testImplementation(libs.junit)
}
// =============================================================================
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
index 5f529f8ce..3cdde6847 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt
@@ -80,6 +80,7 @@ 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.feed.subsTabRows
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet
@@ -310,12 +311,15 @@ private fun SubsPane(
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)
+ val settings = com.sulkta.straw.data.Settings.get()
+ val hideShorts by settings.hideShorts.collectAsState()
+ // subsTabRows reads Hide watched and Hide Shorts from settings itself;
+ // the two collected values are keys here so that flipping either
+ // switch recomputes the rows.
+ val subsRows = remember(feed.items, hideWatched, watchedIds, hideShorts) {
+ subsTabRows(feed.items, watchedIds, settings)
}
+ val filteredItems = subsRows.rows
// Reset pagination when the underlying list changes so the user
// doesn't end up looking at "no more items" after a refresh.
LaunchedEffect(filteredItems) {
@@ -404,7 +408,7 @@ private fun SubsPane(
}
feed.items.isNotEmpty() && filteredItems.isEmpty() -> {
Text(
- "All caught up — nothing unwatched.",
+ subsRows.emptyText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -491,17 +495,6 @@ private fun SubsPane(
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(
diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt
index 7170b9a0f..879e9eadb 100644
--- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt
+++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt
@@ -32,18 +32,42 @@ data class FeedCacheEntry(
)
private const val PREFS = "straw_feed_cache"
-private const val KEY = "cache_v1"
-class FeedCacheStore(context: Context) {
- private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- private val json = Json { ignoreUnknownKeys = true }
+/**
+ * v2: from 2026-10-22 strawcore gives a Short in the RSS feed its
+ * /shorts/ URL, which is how Hide Shorts finds it. A v1 cache holds
+ * Shorts under watch?v= URLs that nothing can tell apart, so it is never
+ * read (it would paint Shorts until each channel refetched) and is
+ * removed when the store is built.
+ */
+internal const val FEED_CACHE_KEY = "cache_v2"
+internal const val FEED_CACHE_LEGACY_KEY = "cache_v1"
+
+class FeedCacheStore internal constructor(
+ private val sp: SharedPreferences,
// Writes serialized off-thread (audit #2 M-2 follow-on). Unlike the StateFlow
// stores, this cache's state is caller-owned (the feed VM's channelCache), so
// there's no live value to re-read — the write captures the encoded snapshot.
// Ordering is safe because the only writer (SubscriptionFeedViewModel) calls
// save()/clear() from a single refresh coroutine (cancels in-flight before
// clear), so enqueue order == intended order and the FIFO dispatcher preserves it.
- private val writer = PrefsWriter(sp)
+ // Passed in so unit tests can give a writer that edits at once: PrefsWriter
+ // launches on StrawApp.globalScope, which only the running app sets up.
+ private val writer: (SharedPreferences.Editor.() -> Unit) -> Unit,
+) {
+ constructor(context: Context) : this(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE))
+
+ /** The store the app uses: writes go through a [PrefsWriter] on [sp]. */
+ internal constructor(sp: SharedPreferences) : this(sp, PrefsWriter(sp)::write)
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ init {
+ // Not routed through `writer`: nothing else ever writes the legacy
+ // key, so there is no ordering to keep, and this keeps the store
+ // constructible where no app scope exists (unit tests).
+ if (sp.contains(FEED_CACHE_LEGACY_KEY)) sp.edit().remove(FEED_CACHE_LEGACY_KEY).apply()
+ }
/**
* Snapshot of the disk cache, filtered by the user-configured TTL.
@@ -51,10 +75,13 @@ class FeedCacheStore(context: Context) {
* Settings.cacheTtl.isForever short-circuits the filter; finite TTLs
* drop entries whose fetchedAt is older than (now - ttl).
*/
- fun load(): Map = runCatching {
- val s = sp.getString(KEY, null) ?: return emptyMap()
+ fun load(): Map =
+ runCatching { load(Settings.get().cacheTtl.value) }.getOrDefault(emptyMap())
+
+ /** [load] with the TTL passed in rather than read from Settings. */
+ internal fun load(ttl: CacheTtl): Map = runCatching {
+ val s = sp.getString(FEED_CACHE_KEY, null) ?: return emptyMap()
val raw = json.decodeFromString