summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-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
9 files changed, 798 insertions, 1 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 3d95b9c..e2d4a50 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-81.28
+85.12
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())
+ }
+}