summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-21 09:03:48 +0000
committerClaude <noreply@anthropic.com>2026-09-21 09:03:48 +0000
commiteb1c69d47aaee463960909f99973da4cf791d801 (patch)
tree1f8bd7fc3ef92c464011ecd090477db670fb3363 /app
parentf3b36bacb95d8c57aa036959ad81f3c79127dd22 (diff)
Subscription resolution, OPML, queue ordering, retention
Discovery was cut, so the share sheet and OPML are the only ways to subscribe. That raises the bar on the resolver: anything that fails to resolve is a show the user cannot add at all. It handles a direct feed, a show page via its link rel=alternate, an Apple Podcasts link via its numeric id, and a YouTube channel or playlist via the RSS endpoint YouTube already publishes -- which means a channel becomes an ordinary subscription and only the download step is special. Handle (@name) URLs carry no channel id and return null rather than being guessed at. OPML is ported from FeeDreader but onto XmlPullParserFactory, so unlike the original it is actually testable off-device. Folder nesting is read and recorded even though Listen has no folders: silently discarding structure the user organised is worse than ignoring it. Queue ordering is pure list operations. Re-adding a queued episode moves it rather than duplicating it, play-next on the episode already playing is a no-op instead of interrupting it, and an out-of-range drag index is ignored rather than thrown. Retention keeps an episode when its position is non-zero even though it is marked played -- that combination means it was resumed, and the audio is in use. Disabled is a flag, not an enormous grace period, because playedAt + Long.MAX_VALUE overflows negative and would delete everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app')
-rw-r--r--app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt90
-rw-r--r--app/src/main/java/org/terst/listen/opml/Opml.kt102
-rw-r--r--app/src/main/java/org/terst/listen/queue/QueueOrdering.kt50
-rw-r--r--app/src/main/java/org/terst/listen/queue/RetentionPolicy.kt46
-rw-r--r--app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt155
-rw-r--r--app/src/test/java/org/terst/listen/opml/OpmlTest.kt140
-rw-r--r--app/src/test/java/org/terst/listen/queue/QueueOrderingTest.kt92
-rw-r--r--app/src/test/java/org/terst/listen/queue/RetentionPolicyTest.kt122
8 files changed, 797 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt b/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt
new file mode 100644
index 0000000..6e96eda
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt
@@ -0,0 +1,90 @@
+package org.terst.listen.feed
+
+import java.net.URI
+
+/**
+ * Turns whatever the user shared into a subscribable feed URL.
+ *
+ * Discovery was cut from scope, so this and OPML import are the only ways in. That raises the
+ * bar: a share that fails to resolve is a subscription the user cannot add at all.
+ *
+ * The pure parts live here so they are testable without a network. The fetch-and-inspect steps
+ * (HTML discovery, the iTunes lookup) compose these with a FeedFetcher.
+ */
+object SubscriptionResolver {
+
+ private val URL_REGEX = Regex("""https?://[^\s<>"']+""")
+ private val LINK_TAG = Regex("""<link\b[^>]*>""", RegexOption.IGNORE_CASE)
+ private val APPLE_ID = Regex("""/id(\d+)""")
+ private val YOUTUBE_CHANNEL = Regex("""/channel/(UC[\w-]+)""")
+ private val YOUTUBE_PLAYLIST = Regex("""[?&]list=([\w-]+)""")
+
+ private val FEED_TYPES = setOf(
+ "application/rss+xml",
+ "application/atom+xml",
+ "application/feed+json",
+ "text/xml",
+ )
+
+ /** Share sheets hand over a page title and a URL together as often as a bare URL. */
+ fun extractUrl(sharedText: String): String? =
+ URL_REGEX.find(sharedText)?.value?.trimEnd('.', ',', ')', ']', '!', ';')
+
+ /**
+ * Apple Podcasts pages are a common share target and are not feeds. The numeric id can be
+ * exchanged for the real feed URL through the public iTunes lookup endpoint.
+ */
+ fun applePodcastId(url: String): String? {
+ val host = hostOf(url) ?: return null
+ if (!host.endsWith("podcasts.apple.com") && !host.endsWith("itunes.apple.com")) return null
+ return APPLE_ID.find(url)?.groupValues?.get(1)
+ }
+
+ fun isYouTube(url: String): Boolean {
+ val host = hostOf(url) ?: return false
+ return host == "youtu.be" || host.removePrefix("www.").removePrefix("m.") == "youtube.com"
+ }
+
+ /**
+ * A YouTube channel publishes an RSS feed, so a channel becomes an ordinary subscription and
+ * only the download step is special.
+ *
+ * Handle (@name) and legacy /c/ URLs carry no channel id and need the page fetched, so they
+ * return null here rather than guessing.
+ */
+ fun youtubeFeedUrl(url: String): String? {
+ if (!isYouTube(url)) return null
+ YOUTUBE_CHANNEL.find(url)?.let {
+ return "https://www.youtube.com/feeds/videos.xml?channel_id=${it.groupValues[1]}"
+ }
+ YOUTUBE_PLAYLIST.find(url)?.let {
+ return "https://www.youtube.com/feeds/videos.xml?playlist_id=${it.groupValues[1]}"
+ }
+ return null
+ }
+
+ /**
+ * Finds a feed declared by a show's web page.
+ *
+ * Parsed with a regex rather than an XML parser on purpose: this is HTML, which is usually
+ * not well-formed XML, and only one element is wanted.
+ */
+ fun findFeedLink(html: String, pageUrl: String): String? =
+ LINK_TAG.findAll(html)
+ .map { it.value }
+ .filter { attr(it, "rel")?.lowercase() == "alternate" }
+ .filter { attr(it, "type")?.lowercase() in FEED_TYPES }
+ .mapNotNull { attr(it, "href") }
+ .firstNotNullOfOrNull { absolutise(it, pageUrl) }
+
+ /** Attribute values may be single- or double-quoted, in any order within the tag. */
+ private fun attr(tag: String, name: String): String? =
+ Regex("""\b$name\s*=\s*["']([^"']*)["']""", RegexOption.IGNORE_CASE)
+ .find(tag)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
+
+ private fun absolutise(href: String, pageUrl: String): String? =
+ runCatching { URI(pageUrl).resolve(href).toString() }.getOrNull()
+
+ private fun hostOf(url: String): String? =
+ runCatching { URI(url).host?.lowercase() }.getOrNull()
+}
diff --git a/app/src/main/java/org/terst/listen/opml/Opml.kt b/app/src/main/java/org/terst/listen/opml/Opml.kt
new file mode 100644
index 0000000..af7e788
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/opml/Opml.kt
@@ -0,0 +1,102 @@
+package org.terst.listen.opml
+
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.InputStream
+import java.io.OutputStream
+
+data class OpmlFeed(
+ val title: String,
+ val xmlUrl: String,
+ val htmlUrl: String? = null,
+ val folder: String? = null,
+)
+
+/**
+ * OPML import and export.
+ *
+ * This is the migration path off another client, so import has to tolerate whatever that client
+ * wrote -- including folder nesting, which Listen has no concept of. Folders are recorded rather
+ * than used, because silently discarding structure the user organised is worse than ignoring it.
+ *
+ * Ported from FeeDreader, but on XmlPullParserFactory rather than android.util.Xml, so it can
+ * actually be tested off-device.
+ */
+object Opml {
+
+ private const val OUTLINE = "outline"
+
+ fun parse(input: InputStream): List<OpmlFeed> {
+ val parser = XmlPullParserFactory.newInstance().newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(input, null)
+
+ val feeds = mutableListOf<OpmlFeed>()
+ // One entry per open <outline>: a folder contributes its name, anything else null. The
+ // current folder is the nearest named ancestor.
+ val openOutlines = mutableListOf<String?>()
+
+ while (parser.next() != XmlPullParser.END_DOCUMENT) {
+ val isOutline = parser.name?.equals(OUTLINE, ignoreCase = true) == true
+ if (!isOutline) continue
+
+ when (parser.eventType) {
+ XmlPullParser.START_TAG -> {
+ val xmlUrl = parser.attr("xmlUrl")
+ if (xmlUrl != null) {
+ feeds += OpmlFeed(
+ title = parser.attr("title") ?: parser.attr("text") ?: xmlUrl,
+ xmlUrl = xmlUrl,
+ htmlUrl = parser.attr("htmlUrl"),
+ folder = openOutlines.filterNotNull().lastOrNull(),
+ )
+ openOutlines += null
+ } else {
+ openOutlines += parser.attr("text") ?: parser.attr("title")
+ }
+ }
+
+ XmlPullParser.END_TAG -> if (openOutlines.isNotEmpty()) {
+ openOutlines.removeAt(openOutlines.lastIndex)
+ }
+ }
+ }
+ return feeds
+ }
+
+ fun write(feeds: List<OpmlFeed>, output: OutputStream) {
+ // The serializer escapes attribute values, which is what keeps an ampersand in a title
+ // or a query string from producing a document nothing can read back.
+ val out = XmlPullParserFactory.newInstance().newSerializer()
+ out.setOutput(output, "UTF-8")
+ out.startDocument("UTF-8", true)
+
+ out.startTag(null, "opml")
+ out.attribute(null, "version", "2.0")
+
+ out.startTag(null, "head")
+ out.startTag(null, "title")
+ out.text("Listen subscriptions")
+ out.endTag(null, "title")
+ out.endTag(null, "head")
+
+ out.startTag(null, "body")
+ for (feed in feeds) {
+ out.startTag(null, OUTLINE)
+ out.attribute(null, "text", feed.title)
+ out.attribute(null, "title", feed.title)
+ out.attribute(null, "type", "rss")
+ out.attribute(null, "xmlUrl", feed.xmlUrl)
+ feed.htmlUrl?.let { out.attribute(null, "htmlUrl", it) }
+ out.endTag(null, OUTLINE)
+ }
+ out.endTag(null, "body")
+
+ out.endTag(null, "opml")
+ out.endDocument()
+ out.flush()
+ }
+
+ private fun XmlPullParser.attr(name: String): String? =
+ getAttributeValue(null, name)?.trim()?.takeIf { it.isNotEmpty() }
+}
diff --git a/app/src/main/java/org/terst/listen/queue/QueueOrdering.kt b/app/src/main/java/org/terst/listen/queue/QueueOrdering.kt
new file mode 100644
index 0000000..4cc6543
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/queue/QueueOrdering.kt
@@ -0,0 +1,50 @@
+package org.terst.listen.queue
+
+/**
+ * Ordering for the single Up Next queue.
+ *
+ * Pure list operations rather than DAO calls, so the behaviour that actually matters -- what
+ * happens when you re-add something already queued, or drag past the end -- is testable directly.
+ * The head of the list is the episode currently playing.
+ */
+object QueueOrdering {
+
+ /** Adds to the end, or moves an already-queued episode there. Never duplicates. */
+ fun append(queue: List<String>, episodeId: String): List<String> =
+ queue.filterNot { it == episodeId } + episodeId
+
+ /**
+ * Puts an episode immediately after the one playing.
+ *
+ * Asking to play next what is already playing is a no-op: moving it behind itself would
+ * interrupt playback, which is never what the gesture means.
+ */
+ fun playNext(queue: List<String>, episodeId: String): List<String> {
+ if (queue.firstOrNull() == episodeId) return queue
+ val rest = queue.filterNot { it == episodeId }
+ if (rest.isEmpty()) return listOf(episodeId)
+ return listOf(rest.first(), episodeId) + rest.drop(1)
+ }
+
+ fun remove(queue: List<String>, episodeId: String): List<String> =
+ queue.filterNot { it == episodeId }
+
+ /** Out-of-range indices are ignored rather than throwing: a drag can report one mid-gesture. */
+ fun move(queue: List<String>, from: Int, to: Int): List<String> {
+ if (from !in queue.indices || to !in queue.indices || from == to) return queue
+ val mutable = queue.toMutableList()
+ mutable.add(to, mutable.removeAt(from))
+ return mutable
+ }
+
+ /**
+ * Drops the finished episode and reports what plays next.
+ *
+ * Returns the new head rather than the removed episode, because the caller's next question is
+ * always "what now?".
+ */
+ fun advance(queue: List<String>): Pair<String?, List<String>> {
+ val remaining = queue.drop(1)
+ return remaining.firstOrNull() to remaining
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/queue/RetentionPolicy.kt b/app/src/main/java/org/terst/listen/queue/RetentionPolicy.kt
new file mode 100644
index 0000000..cd05d32
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/queue/RetentionPolicy.kt
@@ -0,0 +1,46 @@
+package org.terst.listen.queue
+
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+
+/**
+ * Decides when a finished episode's audio can be reclaimed.
+ *
+ * Auto-delete once played, with a grace period. The grace period is the substance of it: an
+ * episode marked played by a mis-tap, or by an outro skip that fired early, has to still be
+ * there when the listener reaches back for it.
+ */
+class RetentionPolicy(
+ private val gracePeriodMs: Long,
+ private val enabled: Boolean = true,
+) {
+
+ fun shouldDelete(episode: EpisodeEntity, nowMs: Long): Boolean {
+ if (!enabled) return false
+ val playedAt = episode.playedAtMs ?: return false
+
+ return (
+ // Only a downloaded file is worth reclaiming; deleting anything else discards the
+ // played record for no disk saved.
+ episode.downloadState == DownloadState.DOWNLOADED &&
+ episode.localPath != null &&
+ // A non-zero position means it was resumed after being marked played, so the
+ // mark is stale and the audio is in use.
+ episode.positionMs == 0L &&
+ nowMs > playedAt + gracePeriodMs
+ )
+ }
+
+ fun selectDeletable(episodes: List<EpisodeEntity>, nowMs: Long): List<EpisodeEntity> =
+ episodes.filter { shouldDelete(it, nowMs) }
+
+ companion object {
+ /**
+ * Keeps everything, for a series worth re-listening to.
+ *
+ * A flag rather than an enormous grace period: `playedAt + Long.MAX_VALUE` overflows to
+ * a negative number and would delete everything immediately.
+ */
+ val Disabled = RetentionPolicy(gracePeriodMs = 0, enabled = false)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt b/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt
new file mode 100644
index 0000000..c219dcf
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt
@@ -0,0 +1,155 @@
+package org.terst.listen.feed
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * With discovery cut from scope, the share sheet is how subscriptions get added. Whatever the
+ * user shares -- a feed, a show's web page, an Apple Podcasts link, a YouTube channel -- has to
+ * come out as something subscribable.
+ *
+ * These cover the parts that need no network. The fetch-and-inspect path is in
+ * SubscriptionResolver itself.
+ */
+class SubscriptionResolverTest {
+
+ @Test
+ fun `extracts a url from shared text`() {
+ val text = "Listen to this https://example.com/feed.xml it's great"
+ assertEquals("https://example.com/feed.xml", SubscriptionResolver.extractUrl(text))
+ }
+
+ @Test
+ fun `extracts a bare url`() {
+ assertEquals("https://example.com/f", SubscriptionResolver.extractUrl("https://example.com/f"))
+ }
+
+ /** Android share sheets routinely wrap the URL in the page title. */
+ @Test
+ fun `extracts a url from a title-plus-link share`() {
+ val text = "The Rest Is History\nhttps://podcasts.apple.com/gb/podcast/id1537788786"
+ assertEquals("https://podcasts.apple.com/gb/podcast/id1537788786", SubscriptionResolver.extractUrl(text))
+ }
+
+ @Test
+ fun `returns null when there is no url`() {
+ assertNull(SubscriptionResolver.extractUrl("just some words"))
+ assertNull(SubscriptionResolver.extractUrl(""))
+ }
+
+ @Test
+ fun `strips trailing punctuation from a shared url`() {
+ assertEquals("https://example.com/f", SubscriptionResolver.extractUrl("see https://example.com/f."))
+ }
+
+ // --- Apple Podcasts -------------------------------------------------------------------
+
+ @Test
+ fun `recognises an apple podcasts url and extracts its id`() {
+ val cases = listOf(
+ "https://podcasts.apple.com/gb/podcast/the-rest-is-history/id1537788786",
+ "https://podcasts.apple.com/us/podcast/id1537788786?i=1000600000000",
+ "https://itunes.apple.com/us/podcast/something/id1537788786",
+ )
+ for (url in cases) {
+ assertEquals("failed for $url", "1537788786", SubscriptionResolver.applePodcastId(url))
+ }
+ }
+
+ @Test
+ fun `non-apple urls have no apple id`() {
+ assertNull(SubscriptionResolver.applePodcastId("https://example.com/podcast/id123"))
+ assertNull(SubscriptionResolver.applePodcastId("https://podcasts.apple.com/gb/podcast/no-id-here"))
+ }
+
+ // --- YouTube --------------------------------------------------------------------------
+
+ /**
+ * A YouTube channel has an RSS feed, which means a channel can be an ordinary subscription
+ * and only the download step is special.
+ */
+ @Test
+ fun `builds a channel feed url from a channel id`() {
+ val resolved = SubscriptionResolver.youtubeFeedUrl("https://www.youtube.com/channel/UCabc123")
+ assertEquals("https://www.youtube.com/feeds/videos.xml?channel_id=UCabc123", resolved)
+ }
+
+ @Test
+ fun `recognises a playlist url`() {
+ val resolved = SubscriptionResolver.youtubeFeedUrl("https://www.youtube.com/playlist?list=PL12345")
+ assertEquals("https://www.youtube.com/feeds/videos.xml?playlist_id=PL12345", resolved)
+ }
+
+ /** Handle and /c/ URLs need the page fetched to find the channel id, so not here. */
+ @Test
+ fun `a handle url cannot be resolved without a fetch`() {
+ assertNull(SubscriptionResolver.youtubeFeedUrl("https://www.youtube.com/@SomeHandle"))
+ }
+
+ @Test
+ fun `a plain video url is not a channel feed`() {
+ assertNull(SubscriptionResolver.youtubeFeedUrl("https://youtu.be/Bo9Z29YjzVk"))
+ }
+
+ @Test
+ fun `non-youtube urls are not channel feeds`() {
+ assertNull(SubscriptionResolver.youtubeFeedUrl("https://example.com/channel/UCabc"))
+ }
+
+ @Test
+ fun `identifies youtube urls`() {
+ assertTrue(SubscriptionResolver.isYouTube("https://www.youtube.com/watch?v=abc"))
+ assertTrue(SubscriptionResolver.isYouTube("https://youtu.be/abc"))
+ assertTrue(SubscriptionResolver.isYouTube("https://m.youtube.com/watch?v=abc"))
+ assertTrue(!SubscriptionResolver.isYouTube("https://example.com/youtube"))
+ }
+
+ // --- HTML feed discovery --------------------------------------------------------------
+
+ @Test
+ fun `finds a feed link in html`() {
+ val html = """
+ <html><head>
+ <link rel="alternate" type="application/rss+xml" href="https://example.com/feed.xml">
+ </head><body>x</body></html>
+ """.trimIndent()
+ assertEquals(
+ "https://example.com/feed.xml",
+ SubscriptionResolver.findFeedLink(html, "https://example.com/show")
+ )
+ }
+
+ @Test
+ fun `resolves a relative feed link against the page url`() {
+ val html = """<link rel="alternate" type="application/rss+xml" href="/feed.xml">"""
+ assertEquals(
+ "https://example.com/feed.xml",
+ SubscriptionResolver.findFeedLink(html, "https://example.com/shows/one")
+ )
+ }
+
+ @Test
+ fun `accepts atom and single-quoted attributes`() {
+ val html = """<link rel='alternate' type='application/atom+xml' href='https://e.com/atom'>"""
+ assertEquals("https://e.com/atom", SubscriptionResolver.findFeedLink(html, "https://e.com"))
+ }
+
+ @Test
+ fun `handles attributes in any order`() {
+ val html = """<link href="https://e.com/f.xml" type="application/rss+xml" rel="alternate">"""
+ assertEquals("https://e.com/f.xml", SubscriptionResolver.findFeedLink(html, "https://e.com"))
+ }
+
+ @Test
+ fun `ignores non-feed alternate links`() {
+ val html = """<link rel="alternate" type="text/html" href="https://e.com/other">"""
+ assertNull(SubscriptionResolver.findFeedLink(html, "https://e.com"))
+ }
+
+ @Test
+ fun `returns null when there is no feed link`() {
+ assertNull(SubscriptionResolver.findFeedLink("<html><body>nothing</body></html>", "https://e.com"))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/opml/OpmlTest.kt b/app/src/test/java/org/terst/listen/opml/OpmlTest.kt
new file mode 100644
index 0000000..66edc5d
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/opml/OpmlTest.kt
@@ -0,0 +1,140 @@
+package org.terst.listen.opml
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.ByteArrayOutputStream
+
+/**
+ * OPML is the Pocket Casts migration path, so import has to cope with whatever another client
+ * wrote. It carries subscriptions only -- positions, history and queue do not travel.
+ */
+class OpmlTest {
+
+ private fun parse(xml: String) = Opml.parse(xml.trimIndent().byteInputStream())
+
+ @Test
+ fun `reads a flat subscription list`() {
+ val feeds = parse(
+ """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <opml version="2.0"><head><title>Pocket Casts Feeds</title></head><body>
+ <outline text="A Show" type="rss" xmlUrl="https://a.com/feed" htmlUrl="https://a.com"/>
+ <outline text="B Show" type="rss" xmlUrl="https://b.com/feed"/>
+ </body></opml>
+ """
+ )
+ assertEquals(2, feeds.size)
+ assertEquals("A Show", feeds[0].title)
+ assertEquals("https://a.com/feed", feeds[0].xmlUrl)
+ assertEquals("https://a.com", feeds[0].htmlUrl)
+ assertNull(feeds[1].htmlUrl)
+ }
+
+ @Test
+ fun `prefers title over text when both are present`() {
+ val feeds = parse("""<opml><body><outline text="t" title="T" xmlUrl="https://a/f"/></body></opml>""")
+ assertEquals("T", feeds.single().title)
+ }
+
+ @Test
+ fun `falls back to the url when a feed has no name`() {
+ val feeds = parse("""<opml><body><outline xmlUrl="https://a.com/feed"/></body></opml>""")
+ assertEquals("https://a.com/feed", feeds.single().title)
+ }
+
+ /** Other clients nest subscriptions in folders. Listen has no folders, but must not lose feeds. */
+ @Test
+ fun `reads feeds nested inside folders`() {
+ val feeds = parse(
+ """
+ <opml><body>
+ <outline text="History">
+ <outline text="A Show" xmlUrl="https://a.com/feed"/>
+ <outline text="B Show" xmlUrl="https://b.com/feed"/>
+ </outline>
+ <outline text="Loose" xmlUrl="https://c.com/feed"/>
+ </body></opml>
+ """
+ )
+ assertEquals(3, feeds.size)
+ assertEquals(listOf("A Show", "B Show", "Loose"), feeds.map { it.title })
+ }
+
+ @Test
+ fun `records the folder a feed came from`() {
+ val feeds = parse(
+ """<opml><body><outline text="History">
+ <outline text="A" xmlUrl="https://a/f"/></outline></body></opml>"""
+ )
+ assertEquals("History", feeds.single().folder)
+ }
+
+ @Test
+ fun `an outline without a url is not a feed`() {
+ val feeds = parse("""<opml><body><outline text="Empty Folder"/></body></opml>""")
+ assertTrue(feeds.isEmpty())
+ }
+
+ @Test
+ fun `an empty document yields nothing`() {
+ assertTrue(parse("""<opml version="2.0"><head/><body/></opml>""").isEmpty())
+ }
+
+ @Test
+ fun `unescapes entities in titles`() {
+ val feeds = parse("""<opml><body><outline text="Rock &amp; Roll" xmlUrl="https://a/f"/></body></opml>""")
+ assertEquals("Rock & Roll", feeds.single().title)
+ }
+
+ @Test
+ fun `ignores a url-less outline that is not a folder`() {
+ val feeds = parse("""<opml><body><outline text="x" type="link" url="https://a"/></body></opml>""")
+ assertTrue(feeds.isEmpty())
+ }
+
+ // --- export ---------------------------------------------------------------------------
+
+ private fun write(feeds: List<OpmlFeed>): String {
+ val out = ByteArrayOutputStream()
+ Opml.write(feeds, out)
+ return out.toString("UTF-8")
+ }
+
+ @Test
+ fun `writes a document another client can read`() {
+ val xml = write(listOf(OpmlFeed("A Show", "https://a.com/feed", "https://a.com")))
+ assertTrue(xml, xml.contains("""xmlUrl="https://a.com/feed""""))
+ assertTrue(xml, xml.contains("<opml"))
+ assertTrue(xml, xml.contains("version=\"2.0\""))
+ }
+
+ /** The real requirement: whatever is written must survive a round trip. */
+ @Test
+ fun `survives a round trip`() {
+ val original = listOf(
+ OpmlFeed("Mathematics & Physics", "https://a.com/feed?x=1&y=2", "https://a.com"),
+ OpmlFeed("Plain", "https://b.com/feed", null),
+ )
+ val reparsed = Opml.parse(write(original).byteInputStream())
+ assertEquals(original.map { it.title }, reparsed.map { it.title })
+ assertEquals(original.map { it.xmlUrl }, reparsed.map { it.xmlUrl })
+ assertEquals(original.map { it.htmlUrl }, reparsed.map { it.htmlUrl })
+ }
+
+ /** Ampersands in titles and query strings are the classic thing that breaks an export. */
+ @Test
+ fun `escapes special characters`() {
+ val xml = write(listOf(OpmlFeed("A & B", "https://a.com/f?x=1&y=2", null)))
+ assertTrue("raw ampersand would make this invalid XML", !xml.contains("A & B"))
+ assertTrue(xml.contains("&amp;"))
+ }
+
+ @Test
+ fun `writes an empty list without failing`() {
+ val xml = write(emptyList())
+ assertTrue(xml.contains("<opml"))
+ assertTrue(Opml.parse(xml.byteInputStream()).isEmpty())
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/queue/QueueOrderingTest.kt b/app/src/test/java/org/terst/listen/queue/QueueOrderingTest.kt
new file mode 100644
index 0000000..556351f
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/queue/QueueOrderingTest.kt
@@ -0,0 +1,92 @@
+package org.terst.listen.queue
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/** The single Up Next queue. Ordering is the whole contract, so it is pure and tested directly. */
+class QueueOrderingTest {
+
+ private val queue = listOf("a", "b", "c")
+
+ @Test
+ fun `appending adds to the end`() {
+ assertEquals(listOf("a", "b", "c", "d"), QueueOrdering.append(queue, "d"))
+ }
+
+ @Test
+ fun `playing next inserts after the current episode`() {
+ assertEquals(listOf("a", "d", "b", "c"), QueueOrdering.playNext(queue, "d"))
+ }
+
+ @Test
+ fun `play next on an empty queue just adds it`() {
+ assertEquals(listOf("d"), QueueOrdering.playNext(emptyList(), "d"))
+ }
+
+ /** Re-adding something already queued moves it rather than duplicating it. */
+ @Test
+ fun `appending an episode already queued moves it to the end`() {
+ assertEquals(listOf("b", "c", "a"), QueueOrdering.append(queue, "a"))
+ }
+
+ @Test
+ fun `play next on an episode already queued moves it up`() {
+ assertEquals(listOf("a", "c", "b"), QueueOrdering.playNext(queue, "c"))
+ }
+
+ /** Asking to play next what is already playing must not reorder anything. */
+ @Test
+ fun `play next on the current episode is a no-op`() {
+ assertEquals(listOf("a", "b", "c"), QueueOrdering.playNext(queue, "a"))
+ }
+
+ @Test
+ fun `removing drops the episode`() {
+ assertEquals(listOf("a", "c"), QueueOrdering.remove(queue, "b"))
+ }
+
+ @Test
+ fun `removing something absent changes nothing`() {
+ assertEquals(queue, QueueOrdering.remove(queue, "zzz"))
+ }
+
+ @Test
+ fun `moving reorders`() {
+ assertEquals(listOf("b", "c", "a"), QueueOrdering.move(queue, from = 0, to = 2))
+ assertEquals(listOf("c", "a", "b"), QueueOrdering.move(queue, from = 2, to = 0))
+ }
+
+ @Test
+ fun `moving to the same place changes nothing`() {
+ assertEquals(queue, QueueOrdering.move(queue, from = 1, to = 1))
+ }
+
+ /** A drag can report an out-of-range index; it must not throw mid-gesture. */
+ @Test
+ fun `out of range moves are ignored`() {
+ assertEquals(queue, QueueOrdering.move(queue, from = -1, to = 1))
+ assertEquals(queue, QueueOrdering.move(queue, from = 0, to = 99))
+ assertEquals(queue, QueueOrdering.move(queue, from = 99, to = 0))
+ }
+
+ @Test
+ fun `advancing drops the finished episode and returns the next`() {
+ val (next, remaining) = QueueOrdering.advance(queue)
+ assertEquals("b", next)
+ assertEquals(listOf("b", "c"), remaining)
+ }
+
+ @Test
+ fun `advancing an empty queue yields nothing`() {
+ val (next, remaining) = QueueOrdering.advance(emptyList())
+ assertEquals(null, next)
+ assertEquals(emptyList<String>(), remaining)
+ }
+
+ @Test
+ fun `advancing the last episode empties the queue`() {
+ val (next, remaining) = QueueOrdering.advance(listOf("a"))
+ assertEquals(null, next)
+ assertEquals(emptyList<String>(), remaining)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/queue/RetentionPolicyTest.kt b/app/src/test/java/org/terst/listen/queue/RetentionPolicyTest.kt
new file mode 100644
index 0000000..280ddb3
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/queue/RetentionPolicyTest.kt
@@ -0,0 +1,122 @@
+package org.terst.listen.queue
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+
+/**
+ * Auto-delete once played, with a grace period.
+ *
+ * The grace period is the whole point: an episode marked played by a mis-tap, or by a skip-outro
+ * that fired early, must still be there when the listener goes back for it a minute later.
+ */
+class RetentionPolicyTest {
+
+ private val hour = 3_600_000L
+ private val day = 24 * hour
+ private val policy = RetentionPolicy(gracePeriodMs = day)
+
+ private fun episode(
+ id: String = "e1",
+ playedAtMs: Long? = null,
+ downloadState: DownloadState = DownloadState.DOWNLOADED,
+ localPath: String? = "/data/e1.mp3",
+ positionMs: Long = 0,
+ ) = EpisodeEntity(
+ id = id,
+ subscriptionId = "s1",
+ guid = id,
+ title = "Episode",
+ mediaUrl = "https://cdn/e.mp3",
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ playedAtMs = playedAtMs,
+ downloadState = downloadState,
+ localPath = localPath,
+ positionMs = positionMs,
+ )
+
+ @Test
+ fun `an unplayed download is kept`() {
+ assertFalse(policy.shouldDelete(episode(playedAtMs = null), nowMs = 100 * day))
+ }
+
+ @Test
+ fun `a played download past its grace period is deleted`() {
+ assertTrue(policy.shouldDelete(episode(playedAtMs = 0), nowMs = day + 1))
+ }
+
+ /** The mis-tap case. */
+ @Test
+ fun `a just-played download is kept during the grace period`() {
+ assertFalse(policy.shouldDelete(episode(playedAtMs = 10 * day), nowMs = 10 * day + hour))
+ }
+
+ @Test
+ fun `the boundary keeps the episode`() {
+ val playedAt = 10 * day
+ assertFalse("exactly at the boundary is still within grace",
+ policy.shouldDelete(episode(playedAtMs = playedAt), nowMs = playedAt + day))
+ assertTrue(policy.shouldDelete(episode(playedAtMs = playedAt), nowMs = playedAt + day + 1))
+ }
+
+ /** Nothing on disk means nothing to reclaim; deleting would only lose the played record. */
+ @Test
+ fun `an episode that was never downloaded is not deleted`() {
+ assertFalse(
+ policy.shouldDelete(
+ episode(playedAtMs = 0, downloadState = DownloadState.NONE, localPath = null),
+ nowMs = 100 * day,
+ )
+ )
+ }
+
+ @Test
+ fun `an in-flight download is never deleted`() {
+ for (state in listOf(DownloadState.QUEUED, DownloadState.DOWNLOADING)) {
+ assertFalse(
+ "state $state should not be deleted",
+ policy.shouldDelete(episode(playedAtMs = 0, downloadState = state), nowMs = 100 * day),
+ )
+ }
+ }
+
+ /**
+ * A resumed episode is back in progress. Its played mark is stale and deleting the file
+ * would take the audio out from under the listener.
+ */
+ @Test
+ fun `a played episode that has been resumed is kept`() {
+ assertFalse(
+ policy.shouldDelete(episode(playedAtMs = 0, positionMs = 500_000), nowMs = 100 * day)
+ )
+ }
+
+ @Test
+ fun `selects only the deletable episodes from a batch`() {
+ val candidates = listOf(
+ episode(id = "old-played", playedAtMs = 0),
+ episode(id = "recent-played", playedAtMs = 100 * day),
+ episode(id = "unplayed", playedAtMs = null),
+ episode(id = "resumed", playedAtMs = 0, positionMs = 90_000),
+ )
+ val deletable = policy.selectDeletable(candidates, nowMs = 100 * day + hour).map { it.id }
+ assertEquals(listOf("old-played"), deletable)
+ }
+
+ @Test
+ fun `a zero grace period deletes as soon as an episode is played`() {
+ val immediate = RetentionPolicy(gracePeriodMs = 0)
+ assertTrue(immediate.shouldDelete(episode(playedAtMs = 10), nowMs = 11))
+ }
+
+ /** Keeping everything has to be expressible, for a series worth re-listening to. */
+ @Test
+ fun `retention can be disabled entirely`() {
+ assertFalse(RetentionPolicy.Disabled.shouldDelete(episode(playedAtMs = 0), nowMs = 1000 * day))
+ assertTrue(RetentionPolicy.Disabled.selectDeletable(listOf(episode(playedAtMs = 0)), 1000 * day).isEmpty())
+ }
+}