Hide Shorts in the subscription feed (on by default) #31
19 changed files with 951 additions and 65 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// `<link rel="alternate">` points at `https://www.youtube.com/shorts/<id>`
|
||||
// where every other upload points at `/watch?v=<id>`. 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 `<link rel="alternate">` 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<Vec<SearchItem>> {
|
|||
let mut uploader_url = String::new();
|
||||
let mut thumbnail: Option<String> = None;
|
||||
let mut published = String::new();
|
||||
// hrefs of the entry's <link rel="alternate"> elements, in order;
|
||||
// together they decide Short or not (see agreed_alternate).
|
||||
let mut alternate_hrefs: Vec<String> = Vec::new();
|
||||
|
||||
// What text-collecting state we're in. Replaced per element open.
|
||||
let mut text_target: Option<TextTarget> = None;
|
||||
|
|
@ -287,6 +304,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
|
|||
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<Vec<SearchItem>> {
|
|||
}
|
||||
}
|
||||
}
|
||||
// <link rel="alternate" href="..."/> 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<Vec<SearchItem>> {
|
|||
// 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<Vec<SearchItem>> {
|
|||
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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
|
||||
<link rel="self" href="http://www.youtube.com/feeds/videos.xml?channel_id=UCuAXFkgsw1L7xaCfnd5JJOw"/>
|
||||
<id>yt:channel:uAXFkgsw1L7xaCfnd5JJOw</id>
|
||||
<yt:channelId>uAXFkgsw1L7xaCfnd5JJOw</yt:channelId>
|
||||
<title>Rick Astley</title>
|
||||
<link rel="alternate" href="https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"/>
|
||||
<author>
|
||||
<name>Rick Astley</name>
|
||||
<uri>https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw</uri>
|
||||
</author>
|
||||
<published>2015-02-01T16:32:30+00:00</published>
|
||||
<entry>
|
||||
<id>yt:video:E_MGy41IYVw</id>
|
||||
<yt:videoId>E_MGy41IYVw</yt:videoId>
|
||||
<yt:channelId>UCuAXFkgsw1L7xaCfnd5JJOw</yt:channelId>
|
||||
<title>38 years on, and the music video still hits</title>
|
||||
<link rel="alternate" href="https://www.youtube.com/shorts/E_MGy41IYVw"/>
|
||||
<author>
|
||||
<name>Rick Astley</name>
|
||||
<uri>https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw</uri>
|
||||
</author>
|
||||
<published>2026-09-20T08:52:27+00:00</published>
|
||||
<updated>2026-10-08T20:00:28+00:00</updated>
|
||||
<media:group>
|
||||
<media:title>38 years on, and the music video still hits</media:title>
|
||||
<media:content url="https://www.youtube.com/v/E_MGy41IYVw?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
|
||||
<media:thumbnail url="https://i2.ytimg.com/vi/E_MGy41IYVw/hqdefault.jpg" width="480" height="360"/>
|
||||
<media:description></media:description>
|
||||
</media:group>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>yt:video:PXC_PYeB6F8</id>
|
||||
<yt:videoId>PXC_PYeB6F8</yt:videoId>
|
||||
<yt:channelId>UCuAXFkgsw1L7xaCfnd5JJOw</yt:channelId>
|
||||
<title>Rick Astley - Angels On My Side (Live at The O2, London, April 2026)</title>
|
||||
<link rel="alternate" href="https://www.youtube.com/watch?v=PXC_PYeB6F8"/>
|
||||
<author>
|
||||
<name>Rick Astley</name>
|
||||
<uri>https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw</uri>
|
||||
</author>
|
||||
<published>2026-06-26T16:00:07+00:00</published>
|
||||
<updated>2026-09-30T04:12:50+00:00</updated>
|
||||
<media:group>
|
||||
<media:title>Rick Astley - Angels On My Side</media:title>
|
||||
<media:content url="https://www.youtube.com/v/PXC_PYeB6F8?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
|
||||
<media:thumbnail url="https://i1.ytimg.com/vi/PXC_PYeB6F8/hqdefault.jpg" width="480" height="360"/>
|
||||
<media:description>Taken from the Reflections Tour.</media:description>
|
||||
</media:group>
|
||||
</entry>
|
||||
</feed>"#;
|
||||
|
||||
/// A minimal entry; `link` is inserted verbatim (empty for none).
|
||||
fn entry(id: &str, link: &str) -> String {
|
||||
format!(
|
||||
"<entry><yt:videoId>{id}</yt:videoId><title>t {id}</title>{link}\
|
||||
<published>2026-10-01T00:00:00+00:00</published></entry>"
|
||||
)
|
||||
}
|
||||
|
||||
fn feed(entries: &[String]) -> String {
|
||||
format!(
|
||||
"<feed xmlns:yt=\"http://www.youtube.com/xml/schemas/2015\" \
|
||||
xmlns=\"http://www.w3.org/2005/Atom\">{}</feed>",
|
||||
entries.concat()
|
||||
)
|
||||
}
|
||||
|
||||
fn urls(body: &str) -> Vec<String> {
|
||||
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#"<link rel="alternate" href="https://www.youtube.com/shorts/BBBBBBBBBB2"/>"#,
|
||||
)]);
|
||||
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#"<link rel="related" href="https://www.youtube.com/shorts/AAAAAAAAAA1"/>"#,
|
||||
),
|
||||
entry(
|
||||
"AAAAAAAAAA2",
|
||||
r#"<link href="https://www.youtube.com/shorts/AAAAAAAAAA2"/>"#,
|
||||
),
|
||||
]);
|
||||
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#"<link rel="alternate" href="https://www.youtube.com/shorts/AAAAAAAAAA1"/>"#,
|
||||
),
|
||||
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#"<link rel="alternate" href="{href}"/>"#)
|
||||
}
|
||||
|
||||
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#"<link rel="alternate" href="https://www.youtube.com/shorts/AAAAAAAAAA1"/>"#,
|
||||
)]);
|
||||
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")]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/<id> 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<String, FeedCacheEntry> = runCatching {
|
||||
val s = sp.getString(KEY, null) ?: return emptyMap()
|
||||
fun load(): Map<String, FeedCacheEntry> =
|
||||
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<String, FeedCacheEntry> = runCatching {
|
||||
val s = sp.getString(FEED_CACHE_KEY, null) ?: return emptyMap()
|
||||
val raw = json.decodeFromString<Map<String, FeedCacheEntry>>(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<String, FeedCacheEntry>) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> = _latestKnownVname.asStateFlow()
|
||||
|
||||
/**
|
||||
* Hide YouTube Shorts everywhere. Detection is multi-signal because
|
||||
* each surface gives different hints:
|
||||
* - Search + ChannelScreen results: URL pattern `/shorts/<id>` 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<Boolean> = _hideShorts.asStateFlow()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<StreamItem>, 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<StreamItem>,
|
||||
watchedIds: Set<String>,
|
||||
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()
|
||||
|
|
@ -381,8 +381,11 @@ class SubscriptionFeedViewModel : ViewModel() {
|
|||
* refresh + clearInMemoryCache can cancel it.
|
||||
*/
|
||||
private fun enrichVisibleItems(items: List<StreamItem>, channelsSnapshot: List<ChannelRef>) {
|
||||
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<StreamItem>,
|
||||
hideShorts: Boolean,
|
||||
count: Int,
|
||||
): List<StreamItem> =
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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/<id>` — 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/<id>` 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<StreamItem>, settings: SettingsStore): List<StreamItem> =
|
||||
applyContentFilters(items, hideShorts = settings.hideShorts.value)
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Any?> = emptyMap()) : SharedPreferences {
|
||||
val values: MutableMap<String, Any?> = initial.toMutableMap()
|
||||
|
||||
override fun getAll(): MutableMap<String, *> = 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<String>?): MutableSet<String>? =
|
||||
if (key in values) values[key] as MutableSet<String>? 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<String, Any?>()
|
||||
private val removes = mutableSetOf<String>()
|
||||
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<String>?) = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, FeedCacheEntry>(), 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, FeedCacheEntry>(), store.load(CacheTtl.Forever))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Any?>): 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)))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Any?>) =
|
||||
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<StreamItem>(), 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<StreamItem>(), 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<StreamItem>(), r.rows)
|
||||
assertEquals(0, r.shortsHidden)
|
||||
assertEquals("All caught up — nothing unwatched.", r.emptyText)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue