From 1f94be7ef7b6242675d77c9e64d75968d5d06135 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 07:57:05 -0700 Subject: [PATCH 01/14] feed: keep the Shorts signal the channel RSS feed already carries A Short's in the channel feed points at https://www.youtube.com/shorts/; every other upload points at /watch?v=. parse_rss threw that link away and built a watch URL for every entry, so the app's hide-Shorts filter could only guess from titles. Measured 2026-10-22 on 7 public channel feeds (91 entries, channels with many, few and no Shorts and two that stream live): the 33 entries with a /shorts/ link were exactly the entries listed in the channel's Shorts playlist feed (playlist_id=UUSH...), and none of the 58 /watch entries were. Running parse_rss over those captured feeds gives the same 33. parse_rss now gives an entry its /shorts/ URL when its alternate link is exactly that form for its own video id, and the watch URL otherwise. No extra request: it is the same feed. --- rust/strawcore/src/feed.rs | 245 ++++++++++++++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 5 deletions(-) diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index ecf7e4015..73d29b17c 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,8 @@ 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(); + // href of the entry's ; decides Short or not. + let mut alternate_href = String::new(); // What text-collecting state we're in. Replaced per element open. let mut text_target: Option = None; @@ -287,6 +303,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { uploader_url.clear(); thumbnail = None; published.clear(); + alternate_href.clear(); } if !in_entry { continue; @@ -317,6 +334,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_href = h; + } + } } Ok(Event::Text(t)) => { if !in_entry { @@ -346,7 +379,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, &alternate_href), title: title.clone(), uploader: uploader.clone(), uploader_url: if uploader_url.is_empty() { @@ -397,6 +430,21 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { Some(items) } +/// 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 +562,190 @@ 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" + ); + } + } +} -- 2.49.1 From 28bc38bb0bd1907d98f6433d18b72820f3b0bc17 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 08:02:19 -0700 Subject: [PATCH 02/14] build: put junit on the app's unit-test classpath strawApp/src/test had no test framework, so LogDumpScrubTest was never compiled or run. With junit added, `./gradlew :strawApp:testDebugUnitTest` compiles and runs it: 10 tests, 0 failures (run 2026-10-22 in the straw-build image). CI still runs only assembleDebug; this does not change the workflow. --- strawApp/build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) 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) } // ============================================================================= -- 2.49.1 From 09637b9cc5afe074f896bda22d980cd5b831750d Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 08:05:20 -0700 Subject: [PATCH 03/14] settings: Hide Shorts is on by default An install that never touched the switch now hides Shorts; the switch still shows them again. The key stays hide_shorts_v1 and only its default changes, which moves exactly the untouched installs: setHideShorts is the only writer of the key, its only caller is the Settings switch, and since it was added in 06bd17d82 it has skipped the write when the value did not change. So no install can hold a stored false that came from the old default; a stored false means the user turned the switch on and off, and it is kept. Tests pin all three cases (no value, false, true). SettingsStore takes its SharedPreferences through an internal constructor so a JVM test can build it; the Context constructor is unchanged for the app. The Settings description now says what the switch does. --- .../com/sulkta/straw/data/SettingsStore.kt | 29 +++---- .../straw/feature/settings/SettingsScreen.kt | 4 +- .../straw/data/FakeSharedPreferences.kt | 76 +++++++++++++++++++ .../straw/data/HideShortsDefaultTest.kt | 32 ++++++++ 4 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 strawApp/src/test/kotlin/com/sulkta/straw/data/FakeSharedPreferences.kt create mode 100644 strawApp/src/test/kotlin/com/sulkta/straw/data/HideShortsDefaultTest.kt diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt index 9fe25bee0..0f477bd74 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -143,6 +143,7 @@ private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1" private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1" private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1" private const val KEY_HIDE_SHORTS = "hide_shorts_v1" +private const val HIDE_SHORTS_DEFAULT = true private const val KEY_HIDE_WATCHED = "hide_watched_v1" private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1" private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_v1" @@ -152,8 +153,8 @@ private const val KEY_CACHE_TTL = "cache_ttl_v1" private const val KEY_BG_FEED_REFRESH_ENABLED = "bg_feed_refresh_enabled_v1" private const val KEY_BG_FEED_REFRESH_INTERVAL = "bg_feed_refresh_interval_v1" -class SettingsStore(context: Context) { - private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +class SettingsStore internal constructor(private val sp: SharedPreferences) { + constructor(context: Context) : this(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)) // SP writes serialized off the calling thread (audit #2 M-2): the // importer toggles SponsorBlock categories on Dispatchers.IO while the @@ -255,20 +256,20 @@ class SettingsStore(context: Context) { val latestKnownVname: StateFlow = _latestKnownVname.asStateFlow() /** - * Hide YouTube Shorts everywhere. Detection is multi-signal because - * each surface gives different hints: - * - Search + ChannelScreen results: URL pattern `/shorts/` is - * reliable (strawcore preserves it). - * - Subscription RSS feed: URLs come back as canonical `watch?v=` - * so URL alone won't trip; fall back to title containing - * "#shorts" / "#Shorts" / "(shorts)" which most short uploaders - * include. - * Filter is best-effort — a hand-tagged short with a clean title - * in the subs feed will slip through until a future build plumbs an - * isShort flag through strawcore-core. + * Hide YouTube Shorts. ON by default since 2026-10-22; the switch in + * Settings shows them again. + * What counts as a Short is ContentFilter.looksLikeShort. + * + * The default changed from false to true without a new key, and that + * is enough to move every install that never touched the switch: + * setHideShorts is the only writer of this key, it is called only from + * the Settings switch, and it has skipped the write when the value is + * unchanged since it was added (06bd17d82). So an untouched install has + * no stored value and now reads true, and a stored false can only mean + * the user turned the switch on and then off again, which is kept. */ private val _hideShorts = MutableStateFlow( - sp.getBoolean(KEY_HIDE_SHORTS, false), + sp.getBoolean(KEY_HIDE_SHORTS, HIDE_SHORTS_DEFAULT), ) val hideShorts: StateFlow = _hideShorts.asStateFlow() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt index 35dee1e58..f08def7da 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -358,9 +358,7 @@ fun SettingsScreen() { fontWeight = FontWeight.SemiBold, ) Text( - "Drop /shorts/ URLs from search + channel pages " + - "and best-effort filter (\"#shorts\" tag) on the " + - "subs feed.", + "Keep YouTube Shorts out of your subscriptions feed.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/data/FakeSharedPreferences.kt b/strawApp/src/test/kotlin/com/sulkta/straw/data/FakeSharedPreferences.kt new file mode 100644 index 000000000..4d9238470 --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/data/FakeSharedPreferences.kt @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * In-memory SharedPreferences for JVM unit tests. The stores take a + * SharedPreferences through an internal constructor so they can be built + * here without an Android Context. Edits apply immediately on commit or + * apply; change listeners are not supported (nothing under test uses them). + */ + +package com.sulkta.straw.data + +import android.content.SharedPreferences + +class FakeSharedPreferences(initial: Map = emptyMap()) : SharedPreferences { + val values: MutableMap = initial.toMutableMap() + + override fun getAll(): MutableMap = values.toMutableMap() + override fun getString(key: String, defValue: String?): String? = + if (key in values) values[key] as String? else defValue + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? = + if (key in values) values[key] as MutableSet? else defValues + override fun getInt(key: String, defValue: Int): Int = values[key] as Int? ?: defValue + override fun getLong(key: String, defValue: Long): Long = values[key] as Long? ?: defValue + override fun getFloat(key: String, defValue: Float): Float = values[key] as Float? ?: defValue + override fun getBoolean(key: String, defValue: Boolean): Boolean = + values[key] as Boolean? ?: defValue + override fun contains(key: String): Boolean = key in values + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener, + ) { + throw UnsupportedOperationException() + } + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener, + ) { + throw UnsupportedOperationException() + } + + private inner class Editor : SharedPreferences.Editor { + private val puts = mutableMapOf() + private val removes = mutableSetOf() + private var clearAll = false + + private fun put(key: String, value: Any?): SharedPreferences.Editor { + puts[key] = value + return this + } + + override fun putString(key: String, value: String?) = put(key, value) + override fun putStringSet(key: String, values: MutableSet?) = put(key, values) + override fun putInt(key: String, value: Int) = put(key, value) + override fun putLong(key: String, value: Long) = put(key, value) + override fun putFloat(key: String, value: Float) = put(key, value) + override fun putBoolean(key: String, value: Boolean) = put(key, value) + override fun remove(key: String): SharedPreferences.Editor { + removes += key + return this + } + override fun clear(): SharedPreferences.Editor { + clearAll = true + return this + } + override fun commit(): Boolean { + if (clearAll) values.clear() + removes.forEach { values.remove(it) } + values.putAll(puts) + return true + } + override fun apply() { + commit() + } + } +} diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/data/HideShortsDefaultTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/data/HideShortsDefaultTest.kt new file mode 100644 index 000000000..6a35257ce --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/data/HideShortsDefaultTest.kt @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Hide Shorts is on by default, and an install's own choice is kept. + */ + +package com.sulkta.straw.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HideShortsDefaultTest { + + private fun hideShortsWith(stored: Map): Boolean = + SettingsStore(FakeSharedPreferences(stored)).hideShorts.value + + @Test + fun anInstallThatNeverTouchedTheSwitchHidesShorts() { + assertEquals(true, hideShortsWith(emptyMap())) + } + + @Test + fun anInstallThatTurnedTheSwitchOffKeepsShowingShorts() { + assertEquals(false, hideShortsWith(mapOf("hide_shorts_v1" to false))) + } + + @Test + fun anInstallThatTurnedTheSwitchOnKeepsHidingShorts() { + assertEquals(true, hideShortsWith(mapOf("hide_shorts_v1" to true))) + } +} -- 2.49.1 From 8ec2185839c4df3018ad9b01e511418e5c3abacd Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 08:05:20 -0700 Subject: [PATCH 04/14] content filter: a Short is an item with a /shorts/ URL, nothing else The title fallback ("#shorts", "#short", "(shorts)") matched 0 of the 33 Shorts in the 7 channel feeds measured on 2026-10-22, and with Hide Shorts on by default it would hide any long video whose title only contains "#short", such as "#shortfilm". The subscription feed now gets /shorts/ URLs from strawcore. Search (Videos filter, 0 of 79 results over 4 queries) and one channel's Videos tab (0 of 30) returned no Shorts, so the URL check is all that is left to do there. --- .../com/sulkta/straw/util/ContentFilter.kt | 30 ++++++++------ .../sulkta/straw/util/ContentFilterTest.kt | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt index bf878adce..5fc6b6e9c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt @@ -6,7 +6,7 @@ * Pure functions on StreamItem so any list-rendering site can call them * with one line at row-emit time. * - * ships only the shorts heuristic — paid/age require strawcore + * ships only the shorts check — paid/age require strawcore * flag plumbing landing previously. The empty-stub fns are here so the * call sites we add now don't need to change when the flags arrive. */ @@ -16,18 +16,24 @@ package com.sulkta.straw.util import com.sulkta.straw.feature.search.StreamItem /** - * Best-effort short-video detector: - * - URL pattern `/shorts/` — reliable signal from search + - * channel pages (strawcore preserves the original URL shape). - * - Title contains `#shorts` / `#short` / "(shorts)" — fallback for - * items where the URL is the canonical `watch?v=` form (RSS feed - * items always come through this way). + * A Short is an item whose URL has the `/shorts/` shape. + * + * Where that shape comes from, per surface (measured 2026-10-22): + * - Subscription feed: strawcore's RSS parser gives an entry its + * /shorts/ URL exactly when YouTube's channel feed links it there, + * which matched the channel's own Shorts playlist for 33 of 33 Shorts + * across 7 channels (see rust/strawcore/src/feed.rs). + * - Search and channel pages: strawcore builds `watch?v=` URLs, but + * those surfaces did not return Shorts at all (search with the Videos + * filter: 0 of 79 results across 4 queries; one channel's Videos + * tab: 0 of 30), so nothing there needs this check today. + * + * There used to be a title fallback ("#shorts", "#short", "(shorts)"). + * It matched 0 of those 33 Shorts, and with Hide Shorts now on by + * default it would hide any long video whose title merely contains + * "#short" (for example "#shortfilm"), so it is gone. */ -fun looksLikeShort(item: StreamItem): Boolean { - if ("/shorts/" in item.url) return true - val t = item.title.lowercase() - return "#shorts" in t || "#short" in t || "(shorts)" in t -} +fun looksLikeShort(item: StreamItem): Boolean = "/shorts/" in item.url /** * Placeholder until adds an isPaid flag via strawcore-core. diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt new file mode 100644 index 000000000..c4ad149d4 --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * What Hide Shorts drops: items with a /shorts/ URL, and nothing else. + */ + +package com.sulkta.straw.util + +import com.sulkta.straw.feature.search.StreamItem +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContentFilterTest { + + private fun item(url: String, title: String = "t") = + StreamItem(url, title, "u", null, null, 0, 0) + + private val short = item("https://www.youtube.com/shorts/E_MGy41IYVw") + private val long = item("https://www.youtube.com/watch?v=PXC_PYeB6F8") + + @Test + fun hideShortsOnDropsShortsAndKeepsTheRest() { + assertEquals(listOf(long), applyContentFilters(listOf(short, long), hideShorts = true)) + } + + @Test + fun hideShortsOffKeepsEverything() { + assertEquals(listOf(short, long), applyContentFilters(listOf(short, long), hideShorts = false)) + } + + @Test + fun aShortsTagInTheTitleDoesNotHideALongVideo() { + val tagged = listOf( + item("https://www.youtube.com/watch?v=AAAAAAAAAA1", "My #shortfilm"), + item("https://www.youtube.com/watch?v=AAAAAAAAAA2", "Best of #Shorts (shorts)"), + ) + assertEquals(tagged, applyContentFilters(tagged, hideShorts = true)) + } +} -- 2.49.1 From 84f10596d0cb584973569bfaf3767d5649a4bc01 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 08:05:20 -0700 Subject: [PATCH 05/14] feed cache: move to cache_v2 and drop the cache written before /shorts/ URLs A cache_v1 entry holds the feed as it was before strawcore marked Shorts, with every Short under a watch?v= URL, so after an update it would paint those Shorts until each channel happened to be refetched. The store now reads and writes cache_v2 only, and removes cache_v1 when it is built (it was the whole feed, about 225 KB per the store's own estimate, sitting in the same preferences file). The first launch after the update paints without a cached feed and fetches it. load() keeps its behaviour; the body moved to an internal load(ttl) so a JVM test can run it without the Settings singleton. --- .../com/sulkta/straw/data/FeedCacheStore.kt | 35 +++++++++++---- .../straw/data/FeedCacheLegacyKeyTest.kt | 43 +++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 strawApp/src/test/kotlin/com/sulkta/straw/data/FeedCacheLegacyKeyTest.kt 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..34e5e469f 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -32,10 +32,19 @@ 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) +/** + * 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) { + constructor(context: Context) : this(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)) private val json = Json { ignoreUnknownKeys = true } // 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 @@ -45,16 +54,26 @@ class FeedCacheStore(context: Context) { // clear), so enqueue order == intended order and the FIFO dispatcher preserves it. private val writer = PrefsWriter(sp) + 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. * Returns empty map if nothing saved or everything expired. * Settings.cacheTtl.isForever short-circuits the filter; finite TTLs * drop entries whose fetchedAt is older than (now - ttl). */ - 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>(s) - val ttl = Settings.get().cacheTtl.value if (ttl.isForever) return raw val cutoff = System.currentTimeMillis() - ttl.ms raw.filterValues { it.fetchedAt >= cutoff } @@ -63,11 +82,11 @@ class FeedCacheStore(context: Context) { /** Atomic write. Caller is responsible for diffing if needed. */ fun save(map: Map) { val s = json.encodeToString(map) - writer.write { putString(KEY, s) } + writer.write { putString(FEED_CACHE_KEY, s) } } fun clear() { - writer.write { remove(KEY) } + writer.write { remove(FEED_CACHE_KEY) } } } diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/data/FeedCacheLegacyKeyTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/data/FeedCacheLegacyKeyTest.kt new file mode 100644 index 000000000..24c9bd0e5 --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/data/FeedCacheLegacyKeyTest.kt @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * A subscription-feed cache written before Shorts kept their /shorts/ URL + * is never shown, and is removed; the current cache still loads. + */ + +package com.sulkta.straw.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class FeedCacheLegacyKeyTest { + + private fun cacheJson(url: String): String = + """{"https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw":""" + + """{"fetchedAt":1,"items":[{"url":"$url","title":"t","uploader":"u",""" + + """"uploaderUrl":null,"thumbnail":null,"durationSeconds":0,"viewCount":0}]}}""" + + @Test + fun theOldCacheIsNotLoadedAndIsRemoved() { + val sp = FakeSharedPreferences( + mapOf("cache_v1" to cacheJson("https://www.youtube.com/watch?v=E_MGy41IYVw")), + ) + val store = FeedCacheStore(sp) + assertEquals(emptyMap(), store.load(CacheTtl.Forever)) + assertFalse("the old cache must be removed", sp.contains("cache_v1")) + } + + @Test + fun theCurrentCacheLoadsBesideTheOldOne() { + val sp = FakeSharedPreferences( + mapOf( + "cache_v1" to cacheJson("https://www.youtube.com/watch?v=E_MGy41IYVw"), + "cache_v2" to cacheJson("https://www.youtube.com/shorts/E_MGy41IYVw"), + ), + ) + val urls = FeedCacheStore(sp).load(CacheTtl.Forever).values.flatMap { e -> e.items.map { it.url } } + assertEquals(listOf("https://www.youtube.com/shorts/E_MGy41IYVw"), urls) + } +} -- 2.49.1 From 67a831b8e13f71b54e1cc17653f1981494bf56e0 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 08:05:20 -0700 Subject: [PATCH 06/14] feed: spend the metadata backfill on rows that are shown The feed backfills views and duration for its first 30 rows, one strawcore call per row. It counted those 30 before Hide Shorts removed anything, so with the switch on (now the default) calls went to Shorts that are never shown and fewer visible rows got numbers. Hidden Shorts are now dropped before the 30 are counted. Same cap, same number of calls at most. --- .../feature/feed/SubscriptionFeedViewModel.kt | 24 +++++++++- .../feed/FeedEnrichmentCandidatesTest.kt | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/FeedEnrichmentCandidatesTest.kt diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 4281c460b..d53e9ea5e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -381,8 +381,11 @@ class SubscriptionFeedViewModel : ViewModel() { * refresh + clearInMemoryCache can cancel it. */ private fun enrichVisibleItems(items: List, channelsSnapshot: List) { - val take = items.take(ENRICH_HEAD_COUNT) - .filter { it.viewCount <= 0L && it.durationSeconds <= 0L } + val take = feedEnrichmentCandidates( + items, + hideShorts = Settings.get().hideShorts.value, + count = ENRICH_HEAD_COUNT, + ) if (take.isEmpty()) return enrichJob?.cancel() enrichJob = viewModelScope.launch { @@ -480,6 +483,23 @@ class SubscriptionFeedViewModel : ViewModel() { } } +/** + * The feed items the background enrichment should fetch metadata for: the + * first [count] items the Subs tab will actually show that RSS left + * without views and duration. Hidden Shorts are skipped BEFORE the head is + * cut, so with Hide Shorts on the enrichment calls go to visible rows + * instead of being spent on Shorts nobody sees. (Hide watched is not + * applied here; that was already so before Shorts were hidden.) + */ +internal fun feedEnrichmentCandidates( + items: List, + hideShorts: Boolean, + count: Int, +): List = + com.sulkta.straw.util.applyContentFilters(items, hideShorts = hideShorts) + .take(count) + .filter { it.viewCount <= 0L && it.durationSeconds <= 0L } + /** * Convert "2 days ago" / "3 weeks ago" / "Streamed 5 hours ago" style * strings into approximate seconds-ago. Higher = more recent (so default diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/FeedEnrichmentCandidatesTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/FeedEnrichmentCandidatesTest.kt new file mode 100644 index 000000000..35e971a71 --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/FeedEnrichmentCandidatesTest.kt @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The feed's background enrichment spends its calls on rows that are shown. + */ + +package com.sulkta.straw.feature.feed + +import com.sulkta.straw.feature.search.StreamItem +import org.junit.Assert.assertEquals +import org.junit.Test + +class FeedEnrichmentCandidatesTest { + + private fun item(url: String, views: Long = 0, duration: Long = 0) = + StreamItem(url, "t", "u", null, null, duration, views) + + private val s1 = item("https://www.youtube.com/shorts/AAAAAAAAAS1") + private val s2 = item("https://www.youtube.com/shorts/AAAAAAAAAS2") + private val v1 = item("https://www.youtube.com/watch?v=AAAAAAAAAV1") + private val v2 = item("https://www.youtube.com/watch?v=AAAAAAAAAV2") + private val v3 = item("https://www.youtube.com/watch?v=AAAAAAAAAV3") + + @Test + fun withHideShortsOnTheHeadIsCountedOverVisibleRows() { + assertEquals( + listOf(v1, v2), + feedEnrichmentCandidates(listOf(s1, v1, s2, v2, v3), hideShorts = true, count = 2), + ) + } + + @Test + fun withHideShortsOffShortsAreEnrichedLikeAnyRow() { + assertEquals( + listOf(s1, v1), + feedEnrichmentCandidates(listOf(s1, v1, s2, v2, v3), hideShorts = false, count = 2), + ) + } + + @Test + fun rowsThatAlreadyHaveMetadataAreSkipped() { + val done = item("https://www.youtube.com/watch?v=AAAAAAAAAV4", views = 10, duration = 60) + assertEquals(listOf(v1), feedEnrichmentCandidates(listOf(done, v1), hideShorts = true, count = 2)) + } +} -- 2.49.1 From a94a95e0f1efcceacd35c6f346136fd8093e9227 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 12:39:16 -0700 Subject: [PATCH 07/14] feed, search: decide Hide Shorts in tested code, not in the composables The Subs tab (StrawHome) and the search screen each passed the Hide Shorts switch to applyContentFilters themselves. Those lines were the only thing hiding Shorts there, and no test reached them: with either one changed to pass false, all 21 unit tests still passed. Now: - applyContentFilters has a second form that takes the SettingsStore and reads the switch itself. The search screen calls it. - subsTabRows (feature/feed/SubsTabRows.kt) builds the Subs tab's rows: hide watched as before, then that same filter. The Subs tab calls it. extractVideoId moved there with it, unchanged. - The composables still collect the switches and key the remembered list on them, so a flipped switch recomputes the list as before. Tests build a real SettingsStore on in-memory preferences: an untouched install sees no Shorts on the Subs tab or through the settings form of the filter, a stored false shows them, and Hide watched neither hides Shorts nor is read in place of the Shorts switch. Not covered: a composable that stops calling these functions. That would need a Compose UI test, which this module does not have. --- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 22 +++----- .../sulkta/straw/feature/feed/SubsTabRows.kt | 40 ++++++++++++++ .../straw/feature/search/SearchScreen.kt | 8 ++- .../com/sulkta/straw/util/ContentFilter.kt | 10 ++++ .../straw/feature/feed/SubsTabRowsTest.kt | 53 +++++++++++++++++++ .../sulkta/straw/util/ContentFilterTest.kt | 18 +++++++ 6 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt create mode 100644 strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 5f529f8ce..5eaaab31a 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,11 +311,13 @@ private fun SubsPane( watches.map { it.videoId }.filter { it.isNotBlank() }.toSet() } - val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState() + 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 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) + subsTabRows(feed.items, watchedIds, settings) } // Reset pagination when the underlying list changes so the user // doesn't end up looking at "no more items" after a refresh. @@ -491,17 +494,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/feature/feed/SubsTabRows.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt new file mode 100644 index 000000000..bd948f718 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Which subscription-feed items the Subs tab shows. Kept out of the + * composable so the JVM unit tests can run it; the screen only renders + * what this returns. + */ + +package com.sulkta.straw.feature.feed + +import com.sulkta.straw.data.SettingsStore +import com.sulkta.straw.feature.search.StreamItem +import com.sulkta.straw.util.applyContentFilters + +/** + * The Subs tab's rows: with Hide watched on, items whose video id is in + * [watchedIds] are dropped; then the content filters (Hide Shorts) apply + * as set in [settings]. + */ +internal fun subsTabRows( + items: List, + watchedIds: Set, + settings: SettingsStore, +): List { + val unwatched = if (!settings.hideWatched.value) items + else items.filterNot { extractVideoId(it.url) in watchedIds } + return applyContentFilters(unwatched, settings) +} + +/** + * 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() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index 575e8881f..bef776def 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -169,9 +169,13 @@ fun SearchScreen( modifier = Modifier.padding(bottom = 4.dp), ) } - val hideShorts by com.sulkta.straw.data.Settings.get().hideShorts.collectAsState() + val settings = com.sulkta.straw.data.Settings.get() + val hideShorts by settings.hideShorts.collectAsState() + // The filter reads Hide Shorts from settings itself; the + // collected value is a key here so that flipping the switch + // recomputes the list. val filteredResults = remember(state.results, hideShorts) { - com.sulkta.straw.util.applyContentFilters(state.results, hideShorts = hideShorts) + com.sulkta.straw.util.applyContentFilters(state.results, settings) } val listState = rememberLazyListState() // Infinite scroll: near the end of a live result set with a diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt index 5fc6b6e9c..851ad44e0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt @@ -13,6 +13,7 @@ package com.sulkta.straw.util +import com.sulkta.straw.data.SettingsStore import com.sulkta.straw.feature.search.StreamItem /** @@ -64,3 +65,12 @@ fun applyContentFilters( (hidePaid && looksLikePaid(item)) || (hideAgeRestricted && looksLikeAgeRestricted(item)) } + +/** + * [applyContentFilters] with the switches as the user set them in + * [settings]. A screen calls this one rather than passing each switch + * itself, so which setting hides what is decided here, where the unit + * tests reach it, and not in a composable they cannot run. + */ +fun applyContentFilters(items: List, settings: SettingsStore): List = + applyContentFilters(items, hideShorts = settings.hideShorts.value) diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt new file mode 100644 index 000000000..efddc2d65 --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * What the Subs tab shows, with the switches read from a real SettingsStore. + */ + +package com.sulkta.straw.feature.feed + +import com.sulkta.straw.data.FakeSharedPreferences +import com.sulkta.straw.data.SettingsStore +import com.sulkta.straw.feature.search.StreamItem +import org.junit.Assert.assertEquals +import org.junit.Test + +class SubsTabRowsTest { + + private fun item(url: String) = StreamItem(url, "t", "u", null, null, 0, 0) + + private fun settings(vararg stored: Pair) = + SettingsStore(FakeSharedPreferences(mapOf(*stored))) + + private val short = item("https://www.youtube.com/shorts/AAAAAAAAAS1") + private val watchedShort = item("https://www.youtube.com/shorts/AAAAAAAAAS2") + private val long = item("https://www.youtube.com/watch?v=AAAAAAAAAV1") + private val watchedLong = item("https://www.youtube.com/watch?v=AAAAAAAAAV2") + + @Test + fun anInstallThatNeverTouchedTheSwitchSeesNoShortsOnTheSubsTab() { + assertEquals(listOf(long), subsTabRows(listOf(short, long), emptySet(), settings())) + } + + @Test + fun withHideShortsTurnedOffTheSubsTabShowsShorts() { + assertEquals( + listOf(short, long), + subsTabRows(listOf(short, long), emptySet(), settings("hide_shorts_v1" to false)), + ) + } + + @Test + fun hideWatchedDropsWatchedRowsAndLeavesShortsToTheirOwnSwitch() { + val s = settings("hide_watched_v1" to true, "hide_shorts_v1" to false) + assertEquals( + listOf(short, long), + subsTabRows( + listOf(short, watchedShort, long, watchedLong), + setOf("AAAAAAAAAS2", "AAAAAAAAAV2"), + s, + ), + ) + } +} diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt index c4ad149d4..bc81133e3 100644 --- a/strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt +++ b/strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt @@ -7,6 +7,8 @@ package com.sulkta.straw.util +import com.sulkta.straw.data.FakeSharedPreferences +import com.sulkta.straw.data.SettingsStore import com.sulkta.straw.feature.search.StreamItem import org.junit.Assert.assertEquals import org.junit.Test @@ -37,4 +39,20 @@ class ContentFilterTest { ) assertEquals(tagged, applyContentFilters(tagged, hideShorts = true)) } + + @Test + fun withSettingsShortsFollowTheHideShortsSwitch() { + val untouched = SettingsStore(FakeSharedPreferences()) + val switchedOff = SettingsStore(FakeSharedPreferences(mapOf("hide_shorts_v1" to false))) + assertEquals(listOf(long), applyContentFilters(listOf(short, long), untouched)) + assertEquals(listOf(short, long), applyContentFilters(listOf(short, long), switchedOff)) + } + + @Test + fun withSettingsHideWatchedDoesNotHideShorts() { + val s = SettingsStore( + FakeSharedPreferences(mapOf("hide_watched_v1" to true, "hide_shorts_v1" to false)), + ) + assertEquals(listOf(short, long), applyContentFilters(listOf(short, long), s)) + } } -- 2.49.1 From 03107ad32802bfdd9ecf6d9d502a76c481c59a9f Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 12:46:30 -0700 Subject: [PATCH 08/14] feed: when only hidden Shorts are left, the Subs tab says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Hide Shorts on (now the default), a feed whose every item is a Short, or whose only unwatched items are Shorts, left the Subs tab saying "All caught up — nothing unwatched." That was not true: there were unwatched items, and Hide Shorts was hiding them. subsTabRows now also returns how many items Hide Shorts removed from what Hide watched left, and the line to show when nothing is left: "Nothing to show but Shorts, and Hide Shorts is on in Settings." when any Shorts were hidden, and the old line otherwise. The Subs tab shows that line in place of the fixed text. Tests: a feed of only Shorts, watched rows hidden with only Shorts left, and an all-watched feed that still says all caught up. No log line for the hidden count: none of the files involved log anything today, and adding logging is not part of this change. --- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 5 ++- .../sulkta/straw/feature/feed/SubsTabRows.kt | 25 +++++++++++- .../straw/feature/feed/SubsTabRowsTest.kt | 38 +++++++++++++++++-- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 5eaaab31a..3cdde6847 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -316,9 +316,10 @@ private fun SubsPane( // 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 filteredItems = remember(feed.items, hideWatched, watchedIds, hideShorts) { + 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) { @@ -407,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, ) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt index bd948f718..bb7025a82 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt @@ -13,6 +13,24 @@ import com.sulkta.straw.data.SettingsStore import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.applyContentFilters +/** + * What the Subs tab shows: [rows], and [shortsHidden], the number of + * items Hide Shorts removed from what Hide watched left. + */ +internal data class SubsTabRows(val rows: List, val shortsHidden: Int) { + /** + * The line shown when the feed has items but [rows] is empty. If any + * Shorts were hidden, everything Hide watched left was a Short, so + * "nothing unwatched" would not be true. + */ + val emptyText: String + get() = if (shortsHidden > 0) { + "Nothing to show but Shorts, and Hide Shorts is on in Settings." + } else { + "All caught up — nothing unwatched." + } +} + /** * The Subs tab's rows: with Hide watched on, items whose video id is in * [watchedIds] are dropped; then the content filters (Hide Shorts) apply @@ -22,10 +40,13 @@ internal fun subsTabRows( items: List, watchedIds: Set, settings: SettingsStore, -): List { +): SubsTabRows { val unwatched = if (!settings.hideWatched.value) items else items.filterNot { extractVideoId(it.url) in watchedIds } - return applyContentFilters(unwatched, settings) + val rows = applyContentFilters(unwatched, settings) + // The settings form of applyContentFilters hides only Shorts, so every + // item it dropped is a hidden Short. + return SubsTabRows(rows, shortsHidden = unwatched.size - rows.size) } /** diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt index efddc2d65..f38c141d8 100644 --- a/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt +++ b/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt @@ -27,14 +27,14 @@ class SubsTabRowsTest { @Test fun anInstallThatNeverTouchedTheSwitchSeesNoShortsOnTheSubsTab() { - assertEquals(listOf(long), subsTabRows(listOf(short, long), emptySet(), settings())) + assertEquals(listOf(long), subsTabRows(listOf(short, long), emptySet(), settings()).rows) } @Test fun withHideShortsTurnedOffTheSubsTabShowsShorts() { assertEquals( listOf(short, long), - subsTabRows(listOf(short, long), emptySet(), settings("hide_shorts_v1" to false)), + subsTabRows(listOf(short, long), emptySet(), settings("hide_shorts_v1" to false)).rows, ) } @@ -47,7 +47,39 @@ class SubsTabRowsTest { listOf(short, watchedShort, long, watchedLong), setOf("AAAAAAAAAS2", "AAAAAAAAAV2"), s, - ), + ).rows, ) } + + @Test + fun aFeedOfOnlyShortsSaysSoInsteadOfAllCaughtUp() { + val r = subsTabRows(listOf(short, watchedShort), emptySet(), settings()) + assertEquals(emptyList(), r.rows) + assertEquals(2, r.shortsHidden) + assertEquals("Nothing to show but Shorts, and Hide Shorts is on in Settings.", r.emptyText) + } + + @Test + fun whenWatchedRowsAreHiddenAndOnlyShortsAreLeftItSaysShorts() { + val r = subsTabRows( + listOf(watchedLong, short), + setOf("AAAAAAAAAV2"), + settings("hide_watched_v1" to true), + ) + assertEquals(emptyList(), r.rows) + assertEquals(1, r.shortsHidden) + assertEquals("Nothing to show but Shorts, and Hide Shorts is on in Settings.", r.emptyText) + } + + @Test + fun aFeedThatIsAllWatchedStillSaysAllCaughtUp() { + val r = subsTabRows( + listOf(long, watchedLong), + setOf("AAAAAAAAAV1", "AAAAAAAAAV2"), + settings("hide_watched_v1" to true), + ) + assertEquals(emptyList(), r.rows) + assertEquals(0, r.shortsHidden) + assertEquals("All caught up — nothing unwatched.", r.emptyText) + } } -- 2.49.1 From 3daa7a9ed06eb85128cb79f382e4a0b7c6a4ab0f Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 12:46:45 -0700 Subject: [PATCH 09/14] feed cache: pin the keys save and clear write to save() and clear() went through PrefsWriter, which launches on the app-wide coroutine scope that only the running app sets up, so no unit test could call them. With save() writing the old cache_v1 key, or clear() removing it instead of cache_v2, all 21 unit tests still passed. FeedCacheStore now takes its writer as a constructor argument. The app paths are unchanged: both existing constructors pass a PrefsWriter on the same preferences, as before. A test passes a writer that edits the in-memory preferences at once. Tests: save writes cache_v2, writes nothing under cache_v1, and loads back what it saved; clear removes cache_v2. --- .../com/sulkta/straw/data/FeedCacheStore.kt | 20 +++++-- .../straw/data/FeedCacheWriteKeyTest.kt | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 strawApp/src/test/kotlin/com/sulkta/straw/data/FeedCacheWriteKeyTest.kt 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 34e5e469f..879e9eadb 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/FeedCacheStore.kt @@ -43,16 +43,24 @@ private const val PREFS = "straw_feed_cache" 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) { - constructor(context: Context) : this(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)) - private val json = Json { ignoreUnknownKeys = true } +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 @@ -82,11 +90,11 @@ class FeedCacheStore internal constructor(private val sp: SharedPreferences) { /** Atomic write. Caller is responsible for diffing if needed. */ fun save(map: Map) { val s = json.encodeToString(map) - writer.write { putString(FEED_CACHE_KEY, s) } + writer { putString(FEED_CACHE_KEY, s) } } fun clear() { - writer.write { remove(FEED_CACHE_KEY) } + writer { remove(FEED_CACHE_KEY) } } } diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/data/FeedCacheWriteKeyTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/data/FeedCacheWriteKeyTest.kt new file mode 100644 index 000000000..18f03381a --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/data/FeedCacheWriteKeyTest.kt @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The subscription-feed cache is saved under, and cleared from, the + * current key only. + */ + +package com.sulkta.straw.data + +import android.content.SharedPreferences +import com.sulkta.straw.feature.search.StreamItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FeedCacheWriteKeyTest { + + /** A store whose writes edit [sp] at once instead of on the app's scope. */ + private fun storeWritingAtOnce(sp: SharedPreferences) = + FeedCacheStore(sp) { block -> + val editor = sp.edit() + editor.block() + editor.apply() + } + + private val entry = FeedCacheEntry( + fetchedAt = 1, + items = listOf( + StreamItem("https://www.youtube.com/shorts/E_MGy41IYVw", "t", "u", null, null, 0, 0), + ), + ) + private val channel = "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw" + + @Test + fun saveWritesTheCurrentKeyAndNotTheOldOne() { + val sp = FakeSharedPreferences() + val store = storeWritingAtOnce(sp) + store.save(mapOf(channel to entry)) + assertTrue("the cache must be saved under cache_v2", sp.contains("cache_v2")) + assertFalse("nothing may be saved under cache_v1", sp.contains("cache_v1")) + assertEquals(mapOf(channel to entry), store.load(CacheTtl.Forever)) + } + + @Test + fun clearRemovesTheCurrentKey() { + val sp = FakeSharedPreferences() + val store = storeWritingAtOnce(sp) + store.save(mapOf(channel to entry)) + store.clear() + assertFalse("clear must remove cache_v2", sp.contains("cache_v2")) + assertEquals(emptyMap(), store.load(CacheTtl.Forever)) + } +} -- 2.49.1 From 5a67e9ea65dcbd780d0edb88944bb6f3d9bc554e Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 12:47:06 -0700 Subject: [PATCH 10/14] feed: alternate links that disagree mark nothing; pin three link shapes Three parser inputs were not pinned by a test, and changing how each is handled left all 8 strawcore tests passing: - An entity-escaped href (https://...shorts/). It is unescaped as XML and marks a Short. Now tested. - A shorts link whose id differs from the entry's only in letter case. It does not mark a Short: the id is compared exactly, as entry_url's comment says. Now tested. Measured 2026-10-22: YouTube's oEmbed endpoint answered 200 for E_MGy41IYVw, 404 for e_mgy41iyvw and 400 for E_MGY41IYVW. - Two alternate links in one entry. The last one used to win, so "watch, then shorts" hid the video and "shorts, then watch" did not. Decision for the last case: an entry decides from its alternate links only when they are all the same href (agreed_alternate). If they disagree it decides from none and keeps its watch URL, in either order. A self-contradicting feed can then at worst show a Short, never hide a regular upload. In the 326 channel feeds captured for this change on 2026-10-22 (4,647 entries) every entry carried exactly one link, an alternate one, so this changes nothing for any feed seen. Tests: the escaped href, the case variant, and four entries: shorts then watch, watch then shorts, shorts twice then watch (all watch URLs), and the same shorts link twice (a Short). --- rust/strawcore/src/feed.rs | 79 +++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index 73d29b17c..db8952ed4 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -283,8 +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(); - // href of the entry's ; decides Short or not. - let mut alternate_href = 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; @@ -303,7 +304,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { uploader_url.clear(); thumbnail = None; published.clear(); - alternate_href.clear(); + alternate_hrefs.clear(); } if !in_entry { continue; @@ -347,7 +348,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option> { } } if let (true, Some(h)) = (is_alternate, href) { - alternate_href = h; + alternate_hrefs.push(h); } } } @@ -379,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: entry_url(&video_id, &alternate_href), + url: entry_url(&video_id, agreed_alternate(&alternate_hrefs)), title: title.clone(), uploader: uploader.clone(), uploader_url: if uploader_url.is_empty() { @@ -430,6 +431,20 @@ 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 @@ -748,4 +763,58 @@ mod tests { ); } } + + 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_in_any_order() { + 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")]); + } } -- 2.49.1 From d90696a2bbcc7baa6883c4dd4e806dd9faa6af1e Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 12:47:21 -0700 Subject: [PATCH 11/14] content filter: drop a channel-tab count that did not reproduce looksLikeShort's comment said one channel's Videos tab held "0 of 30" Shorts. A review could not reproduce that count, so the number is removed. The comment now says only what holds: search with the Videos filter returned no Shorts (0 of 79 results across 4 queries, unchanged); channel pages list the Videos tab, whose items strawcore-core also gives watch?v= URLs (read in its channel.rs), so this check cannot see a Short there; whether that tab ever lists one was not measured. --- .../kotlin/com/sulkta/straw/util/ContentFilter.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt index 851ad44e0..1786a0286 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt @@ -24,10 +24,13 @@ import com.sulkta.straw.feature.search.StreamItem * /shorts/ URL exactly when YouTube's channel feed links it there, * which matched the channel's own Shorts playlist for 33 of 33 Shorts * across 7 channels (see rust/strawcore/src/feed.rs). - * - Search and channel pages: strawcore builds `watch?v=` URLs, but - * those surfaces did not return Shorts at all (search with the Videos - * filter: 0 of 79 results across 4 queries; one channel's Videos - * tab: 0 of 30), so nothing there needs this check today. + * - Search: strawcore builds `watch?v=` URLs, but search with the + * Videos filter did not return Shorts (0 of 79 results across 4 + * queries), so nothing there needs this check today. + * - Channel pages list the channel's Videos tab, whose items strawcore + * also gives `watch?v=` URLs (read in its source, not measured), so + * this check cannot see a Short there. Whether that tab ever lists a + * Short was not measured. * * There used to be a title fallback ("#shorts", "#short", "(shorts)"). * It matched 0 of those 33 Shorts, and with Hide Shorts now on by -- 2.49.1 From 09a8319a6790360ae65455490496dc4f2871ee28 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 12:51:07 -0700 Subject: [PATCH 12/14] content filter, feed: two wordings that said more than is true - The settings form of applyContentFilters said "a screen calls this one". The channel screen still passes its switch itself; the comment now names the two callers that use it. - A parser test was named "in any order" but checks both orders of two links and one order of three; the name no longer says "any". Comment and test name only; no code changes. --- rust/strawcore/src/feed.rs | 2 +- .../src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index db8952ed4..750c6f567 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -788,7 +788,7 @@ mod tests { } #[test] - fn alternate_links_that_disagree_keep_the_watch_url_in_any_order() { + fn alternate_links_that_disagree_keep_the_watch_url() { let a1 = "AAAAAAAAAA1"; let a2 = "AAAAAAAAAA2"; let a3 = "AAAAAAAAAA3"; diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt index 1786a0286..9b629c8e6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt @@ -71,9 +71,10 @@ fun applyContentFilters( /** * [applyContentFilters] with the switches as the user set them in - * [settings]. A screen calls this one rather than passing each switch - * itself, so which setting hides what is decided here, where the unit - * tests reach it, and not in a composable they cannot run. + * [settings]. The Subs tab (through subsTabRows) and the search screen + * call this one instead of passing each switch themselves, so which + * setting hides what is decided here, where the unit tests reach it, + * and not in a composable they cannot run. */ fun applyContentFilters(items: List, settings: SettingsStore): List = applyContentFilters(items, hideShorts = settings.hideShorts.value) -- 2.49.1 From ab344c80bc361538c851f012a734084e9dc1b2e1 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 12:58:53 -0700 Subject: [PATCH 13/14] ci: run the Rust and JVM unit tests before building the APK The build-apk job ran only ./gradlew :strawApp:assembleDebug, so no unit test ran before an APK was signed and published. With the Shorts check planted to never match, that command still built successfully. Two steps are added after "Auto versionCode from commit count" and before "Assemble debug APK": - Rust unit tests (strawcore): cargo test -p strawcore --lib, in straw/rust. - JVM unit tests: ./gradlew :strawApp:testDebugUnitTest, in straw. Both run under the same ionice/nice as the build step and with the same CARGO_TARGET_DIR; neither has a secret in its environment. Nothing else in the workflow changed: same triggers, same build, signer check and publish steps and conditions. Lint is not added. Run by hand in the straw-build image, in the workflow's order: cargo test 11 passed, rc 0; testDebugUnitTest 31 passed, rc 0; assembleDebug rc 0. With the Shorts check planted to never match, the JVM step exited 1 (6 of 31 failed); with the Rust parser planted to never mark a Short, the Rust step exited 101 (5 of 11 failed). Not run on Forgejo: the workflow only runs on main. --- .forgejo/workflows/build.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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: -- 2.49.1 From 3c2f4d485fda9358ad018774cb9a27a7f59875f6 Mon Sep 17 00:00:00 2001 From: Cobb Date: Thu, 22 Oct 2026 14:39:30 -0700 Subject: [PATCH 14/14] test: the screens take the Hide Shorts decision from the tested functions From the red team over ab344c80b. Three wiring mutants survived the 31 shipped tests: the Subs tab no longer calling subsTabRows, the search screen calling the boolean form with false, and the Subs tab keeping the fixed empty text. The fix round declined them as needing a Compose UI test; a plain JVM test that reads the two screens' source kills each of them (the red team measured each mutant failing exactly one test, and the clean tree passing). Run here in the CI image: testDebugUnitTest BUILD SUCCESSFUL, ScreenWiringTest 2 tests, 0 failures. Correction to ab344c80b: its message says the workflow was not run on Forgejo because it only runs on main. That is not the reason. build.yml also runs on workflow_dispatch, which takes any ref, and the publish step runs on any workflow_dispatch whatever the ref (the step's if: condition), so dispatching this branch to test CI would have published an APK built from the branch. The change was not tested on Forgejo for that reason. --- .../com/sulkta/straw/ScreenWiringTest.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 strawApp/src/test/kotlin/com/sulkta/straw/ScreenWiringTest.kt diff --git a/strawApp/src/test/kotlin/com/sulkta/straw/ScreenWiringTest.kt b/strawApp/src/test/kotlin/com/sulkta/straw/ScreenWiringTest.kt new file mode 100644 index 000000000..586746770 --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/ScreenWiringTest.kt @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta + * SPDX-License-Identifier: GPL-3.0-or-later + * + * The two screens take the Hide Shorts decision from the tested functions (subsTabRows and the + * settings form of applyContentFilters). A refactor that stops calling them would otherwise compile + * and pass every other test. + */ + +package com.sulkta.straw + +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScreenWiringTest { + + private fun src(rel: String): String { + val f = File("src/main/kotlin/com/sulkta/straw/$rel") + assertTrue("source not found: ${f.absolutePath}", f.isFile) + return f.readText() + } + + @Test + fun theSubsTabTakesItsRowsAndEmptyTextFromSubsTabRows() { + val s = src("StrawHome.kt") + assertTrue( + "SubsPane must build its rows with subsTabRows", + Regex("""subsTabRows\(\s*feed\.items\s*,\s*watchedIds\s*,\s*settings\s*\)""").containsMatchIn(s), + ) + assertTrue("SubsPane must show those rows", s.contains("val filteredItems = subsRows.rows")) + assertTrue("SubsPane must show subsTabRows' empty text", s.contains("subsRows.emptyText")) + } + + @Test + fun theSearchScreenUsesTheSettingsFormOfTheFilter() { + assertTrue( + "SearchScreen must filter with the settings form", + src("feature/search/SearchScreen.kt").contains("applyContentFilters(state.results, settings)"), + ) + } +} -- 2.49.1