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>(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 +90,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 { putString(FEED_CACHE_KEY, s) } } fun clear() { - writer.write { remove(KEY) } + writer { remove(FEED_CACHE_KEY) } } } 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/feed/SubsTabRows.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt new file mode 100644 index 000000000..bb7025a82 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubsTabRows.kt @@ -0,0 +1,61 @@ +/* + * 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 + +/** + * 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 + * as set in [settings]. + */ +internal fun subsTabRows( + items: List, + watchedIds: Set, + settings: SettingsStore, +): SubsTabRows { + val unwatched = if (!settings.hideWatched.value) items + else items.filterNot { extractVideoId(it.url) in watchedIds } + 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) +} + +/** + * 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/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/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/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/main/kotlin/com/sulkta/straw/util/ContentFilter.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt index bf878adce..9b629c8e6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/ContentFilter.kt @@ -6,28 +6,38 @@ * 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. */ package com.sulkta.straw.util +import com.sulkta.straw.data.SettingsStore 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: 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 + * 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. @@ -58,3 +68,13 @@ fun applyContentFilters( (hidePaid && looksLikePaid(item)) || (hideAgeRestricted && looksLikeAgeRestricted(item)) } + +/** + * [applyContentFilters] with the switches as the user set them in + * [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) 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)"), + ) + } +} 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/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) + } +} 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)) + } +} 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))) + } +} 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)) + } +} 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..f38c141d8 --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/feature/feed/SubsTabRowsTest.kt @@ -0,0 +1,85 @@ +/* + * 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()).rows) + } + + @Test + fun withHideShortsTurnedOffTheSubsTabShowsShorts() { + assertEquals( + listOf(short, long), + subsTabRows(listOf(short, long), emptySet(), settings("hide_shorts_v1" to false)).rows, + ) + } + + @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, + ).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) + } +} 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..bc81133e3 --- /dev/null +++ b/strawApp/src/test/kotlin/com/sulkta/straw/util/ContentFilterTest.kt @@ -0,0 +1,58 @@ +/* + * 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.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 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)) + } + + @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)) + } +}